From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Cursor Overlay
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Block Menu

PreviousNext

Context-menu actions for nodes in the editor selection.

Selection APIBlock Context MenuDemoPlus

Block Menu is copied UI. It keeps only menu presentation state in React and applies actions to nodes from the editor selection.

Loading…
AutoformatBlock Placeholder

On This Page

Add the kitSelection behaviorActions
Build your editor
Production-ready AI template and reusable components.
Get all-access

Add the kit

BlockMenuKit renders BlockContextMenu above the editable. The copied Editor component renders node-selection presentation.

'use client';
 
import { AIChatPlugin } from '@platejs/ai/react';
import { IndentPlugin } from '@platejs/indent/react';
import { ElementApi, PLUGINS } from 'platejs';
import { definePlatePlugin, useEditor, useEditorReadOnly } from 'platejs/react';
import * as React from 'react';
 
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/components/editor/context-menu';
import { applyBlockAction } from '@/components/editor/transforms';
 
type Value = 'askAI' | null;
 
export function BlockContextMenu({ children }: { children: React.ReactNode }) {
  const editor = useEditor();
  const valueRef = React.useRef<Value>(null);
  const [isTouch, setIsTouch] = React.useState(false);
  const readOnly = useEditorReadOnly();
 
  React.useEffect(() => {
    const update = () => {
      setIsTouch('ontouchstart' in window || navigator.maxTouchPoints > 0);
    };
 
    window.addEventListener('resize', update);
    update();
 
    return () => {
      window.removeEventListener('resize', update);
    };
  }, []);
 
  const handleTurnInto = React.useCallback(
    (action: string) => {
      editor.read.selection.nodes().forEach(([, path]) => {
        applyBlockAction(editor, action, { at: path });
      });
    },
    [editor]
  );
 
  const handleAlign = React.useCallback(
    (align: 'center' | 'left' | 'right') => {
      editor.read.selection.nodes().forEach(([, path]) => {
        editor.update.nodes.set({ textAlign: align }, { at: path });
      });
    },
    [editor]
  );
  const handleIndent = React.useCallback(
    (increase: boolean) => {
      editor.read.selection.nodes().forEach(([, path]) => {
        editor
          .plugin(IndentPlugin)
          .update[increase ? 'increase' : 'decrease']({ nodes: { at: path } });
      });
    },
    [editor]
  );
 
  if (isTouch) {
    return children;
  }
 
  return (
    <ContextMenu modal={false}>
      <ContextMenuTrigger
        onContextMenu={(event) => {
          const { dataset } = event.target as HTMLElement;
          const disabled =
            dataset?.pliteEditor === 'true' ||
            readOnly ||
            dataset?.plateOpenContextMenu === 'false';
 
          if (disabled) {
            event.preventDefault();
            return;
          }
 
          const selectable = (event.target as HTMLElement).closest<HTMLElement>(
            '[data-plite-node="element"]'
          );
          const node = selectable
            ? editor.api.dom.resolvePliteNode(selectable)
            : null;
 
          if (
            ElementApi.isElement(node) &&
            !editor.read.selection.contains(node)
          ) {
            editor.update.selection.setNodes([node]);
          }
        }}
      >
        <div className="w-full">{children}</div>
      </ContextMenuTrigger>
      <ContextMenuContent
        className="w-64"
        onFinalFocus={(e) => {
          e.preventDefault();
          editor.api.dom.focus();
 
          if (valueRef.current === 'askAI') {
            editor.plugin(AIChatPlugin).api.show();
          }
 
          valueRef.current = null;
        }}
      >
        <ContextMenuGroup>
          <ContextMenuItem
            onClick={() => {
              valueRef.current = 'askAI';
            }}
          >
            Ask AI
          </ContextMenuItem>
          <ContextMenuItem
            onClick={() => {
              editor.update.nodes.remove();
              editor.api.dom.focus();
            }}
          >
            Delete
          </ContextMenuItem>
          <ContextMenuItem
            onClick={() => {
              editor.update((tx) => {
                tx.blocks.duplicate();
              });
            }}
          >
            Duplicate
            {/* <ContextMenuShortcut>⌘ + D</ContextMenuShortcut> */}
          </ContextMenuItem>
          <ContextMenuSub>
            <ContextMenuSubTrigger>Turn into</ContextMenuSubTrigger>
            <ContextMenuSubContent className="w-48">
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto(PLUGINS.paragraph);
                }}
              >
                Paragraph
              </ContextMenuItem>
 
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto('heading-1');
                }}
              >
                Heading 1
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto('heading-2');
                }}
              >
                Heading 2
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto('heading-3');
                }}
              >
                Heading 3
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto(PLUGINS.blockquote);
                }}
              >
                Blockquote
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleTurnInto(PLUGINS.codeDrawing);
                }}
              >
                Code Drawing
              </ContextMenuItem>
            </ContextMenuSubContent>
          </ContextMenuSub>
        </ContextMenuGroup>
 
        <ContextMenuGroup>
          <ContextMenuItem
            onClick={() => {
              handleIndent(true);
            }}
          >
            Indent
          </ContextMenuItem>
          <ContextMenuItem
            onClick={() => {
              handleIndent(false);
            }}
          >
            Outdent
          </ContextMenuItem>
          <ContextMenuSub>
            <ContextMenuSubTrigger>Align</ContextMenuSubTrigger>
            <ContextMenuSubContent className="w-48">
              <ContextMenuItem
                onClick={() => {
                  handleAlign('left');
                }}
              >
                Left
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleAlign('center');
                }}
              >
                Center
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => {
                  handleAlign('right');
                }}
              >
                Right
              </ContextMenuItem>
            </ContextMenuSubContent>
          </ContextMenuSub>
        </ContextMenuGroup>
      </ContextMenuContent>
    </ContextMenu>
  );
}
 
export const BlockMenuKit = [
  definePlatePlugin('blockMenuUi', {
    render: { aboveEditable: BlockContextMenu },
  }),
];
'use client';
 
import { AIChatPlugin } from '@platejs/ai/react';
import { IndentPlugin } from '@platejs/indent/react';
import { ElementApi, PLUGINS } from 'platejs';
import { definePlatePlugin, useEditor, useEditorReadOnly } from 'platejs/react';
import * as React from 'react';
 
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/components/editor/context-menu';
import { applyBlockAction } from


































































































































































































































import { createPlateEditor } from "platejs/react";
 
import { BlockMenuKit } from "@/components/editor/block-menu";
 
export const editor = createPlateEditor({
  plugins: [...BlockMenuKit],
});
import { createPlateEditor } from "platejs/react";
 
import { BlockMenuKit } from "@/components/editor/block-menu";
 
export const editor = createPlateEditor({
  plugins: [...BlockMenuKit],
});

Install @platejs/ai only when the menu keeps the Ask AI action.

Selection behavior

Right-clicking a selectable element creates a one-node editor selection when the node is not already selected. An existing multi-node selection stays intact when its selected node opens the menu.

The menu restores editable focus when it closes. Touch devices render the children without the context-menu wrapper, and read-only editors do not open the menu.

Disable the menu on a specific surface with data-plate-open-context-menu={false}:

<PlateElement data-plate-open-context-menu={false} {...props}>
  {children}
</PlateElement>
<PlateElement data-plate-open-context-menu={false} {...props}>
  {children}
</PlateElement>

Actions

The registry component reads editor.read.selection.nodes() and sends each mutation to its canonical owner:

ActionOwner
Ask AIAIChatPlugin
Delete and duplicateeditor node transforms
Turn intoregistry applyBlockAction
Indent and outdentIndentPlugin
Aligneditor node transforms

BlockContextMenu is the public copied component. There is no menu-state or separate selection package API.

  • Open the menu via the drag button or the three-dot menu on specific blocks (e.g. images)
  • Includes a combobox that filters options as you type
  • Supports nested menu options
  • Advanced actions such as "Ask AI", colors, and commenting
  • Beautifully crafted UI
Get the code
'@/components/editor/transforms'
;
type Value = 'askAI' | null;
export function BlockContextMenu({ children }: { children: React.ReactNode }) {
const editor = useEditor();
const valueRef = React.useRef<Value>(null);
const [isTouch, setIsTouch] = React.useState(false);
const readOnly = useEditorReadOnly();
React.useEffect(() => {
const update = () => {
setIsTouch('ontouchstart' in window || navigator.maxTouchPoints > 0);
};
window.addEventListener('resize', update);
update();
return () => {
window.removeEventListener('resize', update);
};
}, []);
const handleTurnInto = React.useCallback(
(action: string) => {
editor.read.selection.nodes().forEach(([, path]) => {
applyBlockAction(editor, action, { at: path });
});
},
[editor]
);
const handleAlign = React.useCallback(
(align: 'center' | 'left' | 'right') => {
editor.read.selection.nodes().forEach(([, path]) => {
editor.update.nodes.set({ textAlign: align }, { at: path });
});
},
[editor]
);
const handleIndent = React.useCallback(
(increase: boolean) => {
editor.read.selection.nodes().forEach(([, path]) => {
editor
.plugin(IndentPlugin)
.update[increase ? 'increase' : 'decrease']({ nodes: { at: path } });
});
},
[editor]
);
if (isTouch) {
return children;
}
return (
<ContextMenu modal={false}>
<ContextMenuTrigger
onContextMenu={(event) => {
const { dataset } = event.target as HTMLElement;
const disabled =
dataset?.pliteEditor === 'true' ||
readOnly ||
dataset?.plateOpenContextMenu === 'false';
if (disabled) {
event.preventDefault();
return;
}
const selectable = (event.target as HTMLElement).closest<HTMLElement>(
'[data-plite-node="element"]'
);
const node = selectable
? editor.api.dom.resolvePliteNode(selectable)
: null;
if (
ElementApi.isElement(node) &&
!editor.read.selection.contains(node)
) {
editor.update.selection.setNodes([node]);
}
}}
>
<div className="w-full">{children}</div>
</ContextMenuTrigger>
<ContextMenuContent
className="w-64"
onFinalFocus={(e) => {
e.preventDefault();
editor.api.dom.focus();
if (valueRef.current === 'askAI') {
editor.plugin(AIChatPlugin).api.show();
}
valueRef.current = null;
}}
>
<ContextMenuGroup>
<ContextMenuItem
onClick={() => {
valueRef.current = 'askAI';
}}
>
Ask AI
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
editor.update.nodes.remove();
editor.api.dom.focus();
}}
>
Delete
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
editor.update((tx) => {
tx.blocks.duplicate();
});
}}
>
Duplicate
{/* <ContextMenuShortcut>⌘ + D</ContextMenuShortcut> */}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Turn into</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem
onClick={() => {
handleTurnInto(PLUGINS.paragraph);
}}
>
Paragraph
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleTurnInto('heading-1');
}}
>
Heading 1
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleTurnInto('heading-2');
}}
>
Heading 2
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleTurnInto('heading-3');
}}
>
Heading 3
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleTurnInto(PLUGINS.blockquote);
}}
>
Blockquote
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleTurnInto(PLUGINS.codeDrawing);
}}
>
Code Drawing
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuGroup>
<ContextMenuGroup>
<ContextMenuItem
onClick={() => {
handleIndent(true);
}}
>
Indent
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleIndent(false);
}}
>
Outdent
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Align</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem
onClick={() => {
handleAlign('left');
}}
>
Left
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleAlign('center');
}}
>
Center
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
handleAlign('right');
}}
>
Right
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
);
}
export const BlockMenuKit = [
definePlatePlugin('blockMenuUi', {
render: { aboveEditable: BlockContextMenu },
}),
];