Block Menu

PreviousNext

Context-menu actions for selected editor blocks.

Block Menu adds a right-click menu on top of block selection. BlockMenuPlugin owns the open state and pointer position; BlockSelectionPlugin decides which blocks the menu edits. The registry BlockContextMenu renders the menu actions.

Loading…

Features

  • Right-click block selection.
  • Context menu open state through openId and position.
  • Delete, duplicate, turn-into, indent, outdent, align, and Ask AI actions.
  • Touch-device and read-only guards.
  • Element-level opt in/out with data-plate-open-context-menu.
  • Plus menu with drag-handle entry, combobox filtering, nested actions, colors, comments, and AI.

Fast Path

Add The Kit

BlockMenuKit spreads BlockSelectionKit and renders BlockContextMenu above the editable.

'use client';
 
import { BlockMenuPlugin } from '@platejs/selection/react';
 
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
import { BlockSelectionKit } from './block-selection-kit';
 
export const BlockMenuKit = [
  ...BlockSelectionKit,
  BlockMenuPlugin.configure({
    render: { aboveEditable: BlockContextMenu },
  }),
];
'use client';
 
import { BlockMenuPlugin } from '@platejs/selection/react';
 
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
import { BlockSelectionKit } from './block-selection-kit';
 
export const BlockMenuKit = [
  ...BlockSelectionKit,
  BlockMenuPlugin.configure({
    render: { aboveEditable: BlockContextMenu },
  }),
];
import { createPlateEditor } from 'platejs/react';
 
import { BlockMenuKit } from '@/components/editor/plugins/block-menu-kit';
 
export const editor = createPlateEditor({
  plugins: BlockMenuKit,
});
import { createPlateEditor } from 'platejs/react';
 
import { BlockMenuKit } from '@/components/editor/plugins/block-menu-kit';
 
export const editor = createPlateEditor({
  plugins: BlockMenuKit,
});

Render The Menu

block-context-menu is the registry UI used by the kit.

'use client';
 
import * as React from 'react';
 
import { AIChatPlugin } from '@platejs/ai/react';
import {
  BLOCK_CONTEXT_MENU_ID,
  BlockMenuPlugin,
  BlockSelectionPlugin,
} from '@platejs/selection/react';
import { KEYS } from 'platejs';
import {
  useEditorPlugin,
  useEditorReadOnly,
  usePluginOption,
} from 'platejs/react';
 
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { setBlockType } from '@/components/editor/transforms';
import { useIsTouchDevice } from '@/hooks/use-is-touch-device';
 
type Value = 'askAI' | null;
 
export function BlockContextMenu({ children }: { children: React.ReactNode }) {
  const { api, editor } = useEditorPlugin(BlockMenuPlugin);
  const [value, setValue] = React.useState<Value>(null);
  const isTouch = useIsTouchDevice();
  const readOnly = useEditorReadOnly();
  const openId = usePluginOption(BlockMenuPlugin, 'openId');
  const isOpen = openId === BLOCK_CONTEXT_MENU_ID;
 
  const handleTurnInto = React.useCallback(
    (type: string) => {
      editor
        .getApi(BlockSelectionPlugin)
        .blockSelection.getNodes()
        .forEach(([, path]) => {
          setBlockType(editor, type, { at: path });
        });
    },
    [editor]
  );
 
  const handleAlign = React.useCallback(
    (align: 'center' | 'left' | 'right') => {
      editor
        .getTransforms(BlockSelectionPlugin)
        .blockSelection.setNodes({ align });
    },
    [editor]
  );
 
  if (isTouch) {
    return children;
  }
 
  return (
    <ContextMenu
      onOpenChange={(open) => {
        if (!open) {
          api.blockMenu.hide();
        }
      }}
      modal={false}
    >
      <ContextMenuTrigger
        asChild
        onContextMenu={(event) => {
          const dataset = (event.target as HTMLElement).dataset;
          const disabled =
            dataset?.slateEditor === 'true' ||
            readOnly ||
            dataset?.plateOpenContextMenu === 'false';
 
          if (disabled) return event.preventDefault();
 
          setTimeout(() => {
            api.blockMenu.show(BLOCK_CONTEXT_MENU_ID, {
              x: event.clientX,
              y: event.clientY,
            });
          }, 0);
        }}
      >
        <div className="w-full">{children}</div>
      </ContextMenuTrigger>
      {isOpen && (
        <ContextMenuContent
          className="w-64"
          onCloseAutoFocus={(e) => {
            e.preventDefault();
            editor.getApi(BlockSelectionPlugin).blockSelection.focus();
 
            if (value === 'askAI') {
              editor.getApi(AIChatPlugin).aiChat.show();
            }
 
            setValue(null);
          }}
        >
          <ContextMenuGroup>
            <ContextMenuItem
              onClick={() => {
                setValue('askAI');
              }}
            >
              Ask AI
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() => {
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.removeNodes();
                editor.tf.focus();
              }}
            >
              Delete
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() => {
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.duplicate();
              }}
            >
              Duplicate
              {/* <ContextMenuShortcut>⌘ + D</ContextMenuShortcut> */}
            </ContextMenuItem>
            <ContextMenuSub>
              <ContextMenuSubTrigger>Turn into</ContextMenuSubTrigger>
              <ContextMenuSubContent className="w-48">
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.p)}>
                  Paragraph
                </ContextMenuItem>
 
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h1)}>
                  Heading 1
                </ContextMenuItem>
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h2)}>
                  Heading 2
                </ContextMenuItem>
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h3)}>
                  Heading 3
                </ContextMenuItem>
                <ContextMenuItem
                  onClick={() => handleTurnInto(KEYS.blockquote)}
                >
                  Blockquote
                </ContextMenuItem>
                <ContextMenuItem
                  onClick={() => handleTurnInto(KEYS.codeDrawing)}
                >
                  Code Drawing
                </ContextMenuItem>
              </ContextMenuSubContent>
            </ContextMenuSub>
          </ContextMenuGroup>
 
          <ContextMenuGroup>
            <ContextMenuItem
              onClick={() =>
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.setIndent(1)
              }
            >
              Indent
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() =>
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.setIndent(-1)
              }
            >
              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>
  );
}
'use client';
 
import * as React from 'react';
 
import { AIChatPlugin } from '@platejs/ai/react';
import {
  BLOCK_CONTEXT_MENU_ID,
  BlockMenuPlugin,
  BlockSelectionPlugin,
} from '@platejs/selection/react';
import { KEYS } from 'platejs';
import {
  useEditorPlugin,
  useEditorReadOnly,
  usePluginOption,
} from 'platejs/react';
 
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { setBlockType } from '@/components/editor/transforms';
import { useIsTouchDevice } from '@/hooks/use-is-touch-device';
 
type Value = 'askAI' | null;
 
export function BlockContextMenu({ children }: { children: React.ReactNode }) {
  const { api, editor } = useEditorPlugin(BlockMenuPlugin);
  const [value, setValue] = React.useState<Value>(null);
  const isTouch = useIsTouchDevice();
  const readOnly = useEditorReadOnly();
  const openId = usePluginOption(BlockMenuPlugin, 'openId');
  const isOpen = openId === BLOCK_CONTEXT_MENU_ID;
 
  const handleTurnInto = React.useCallback(
    (type: string) => {
      editor
        .getApi(BlockSelectionPlugin)
        .blockSelection.getNodes()
        .forEach(([, path]) => {
          setBlockType(editor, type, { at: path });
        });
    },
    [editor]
  );
 
  const handleAlign = React.useCallback(
    (align: 'center' | 'left' | 'right') => {
      editor
        .getTransforms(BlockSelectionPlugin)
        .blockSelection.setNodes({ align });
    },
    [editor]
  );
 
  if (isTouch) {
    return children;
  }
 
  return (
    <ContextMenu
      onOpenChange={(open) => {
        if (!open) {
          api.blockMenu.hide();
        }
      }}
      modal={false}
    >
      <ContextMenuTrigger
        asChild
        onContextMenu={(event) => {
          const dataset = (event.target as HTMLElement).dataset;
          const disabled =
            dataset?.slateEditor === 'true' ||
            readOnly ||
            dataset?.plateOpenContextMenu === 'false';
 
          if (disabled) return event.preventDefault();
 
          setTimeout(() => {
            api.blockMenu.show(BLOCK_CONTEXT_MENU_ID, {
              x: event.clientX,
              y: event.clientY,
            });
          }, 0);
        }}
      >
        <div className="w-full">{children}</div>
      </ContextMenuTrigger>
      {isOpen && (
        <ContextMenuContent
          className="w-64"
          onCloseAutoFocus={(e) => {
            e.preventDefault();
            editor.getApi(BlockSelectionPlugin).blockSelection.focus();
 
            if (value === 'askAI') {
              editor.getApi(AIChatPlugin).aiChat.show();
            }
 
            setValue(null);
          }}
        >
          <ContextMenuGroup>
            <ContextMenuItem
              onClick={() => {
                setValue('askAI');
              }}
            >
              Ask AI
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() => {
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.removeNodes();
                editor.tf.focus();
              }}
            >
              Delete
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() => {
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.duplicate();
              }}
            >
              Duplicate
              {/* <ContextMenuShortcut>⌘ + D</ContextMenuShortcut> */}
            </ContextMenuItem>
            <ContextMenuSub>
              <ContextMenuSubTrigger>Turn into</ContextMenuSubTrigger>
              <ContextMenuSubContent className="w-48">
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.p)}>
                  Paragraph
                </ContextMenuItem>
 
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h1)}>
                  Heading 1
                </ContextMenuItem>
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h2)}>
                  Heading 2
                </ContextMenuItem>
                <ContextMenuItem onClick={() => handleTurnInto(KEYS.h3)}>
                  Heading 3
                </ContextMenuItem>
                <ContextMenuItem
                  onClick={() => handleTurnInto(KEYS.blockquote)}
                >
                  Blockquote
                </ContextMenuItem>
                <ContextMenuItem
                  onClick={() => handleTurnInto(KEYS.codeDrawing)}
                >
                  Code Drawing
                </ContextMenuItem>
              </ContextMenuSubContent>
            </ContextMenuSub>
          </ContextMenuGroup>
 
          <ContextMenuGroup>
            <ContextMenuItem
              onClick={() =>
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.setIndent(1)
              }
            >
              Indent
            </ContextMenuItem>
            <ContextMenuItem
              onClick={() =>
                editor
                  .getTransforms(BlockSelectionPlugin)
                  .blockSelection.setIndent(-1)
              }
            >
              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>
  );
}

Try The Plus Menu

  • 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

Ownership

SurfaceOwnerWhat It Does
BlockMenuPlugin@platejs/selection/reactStores openId and pointer position, then exposes menu show/hide APIs.
BlockSelectionPlugin@platejs/selection/reactSelects the block under the context-menu event and applies actions to selected blocks.
BlockSelectionKitRegistryEnables context-menu selection and filters non-selectable blocks such as columns, code lines, and table cells.
BlockMenuKitRegistryCombines BlockSelectionKit with BlockMenuPlugin.render.aboveEditable.
BlockContextMenuRegistry UIRenders Radix context-menu items and calls block-selection transforms.
block-menu-demoRegistry exampleShows the default menu in the full editor.
block-menu-proPlus exampleAdds drag-handle entry, nested filtering, colors, comments, and AI actions.

The menu is UI state, not document state. The document stores block ids and node properties; it does not store whether a menu is open.

Manual Setup

Install Packages

pnpm add @platejs/selection @platejs/ai
pnpm add @platejs/selection @platejs/ai

@platejs/ai is needed only when you keep the Ask AI item from the registry menu.

Add Plugins

Use BlockSelectionKit when you want to keep the registry selection behavior but replace the menu wiring.

import { BlockMenuPlugin } from '@platejs/selection/react';
import { createPlateEditor } from 'platejs/react';
 
import { BlockSelectionKit } from '@/components/editor/plugins/block-selection-kit';
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
export const editor = createPlateEditor({
  plugins: [
    ...BlockSelectionKit,
    BlockMenuPlugin.configure({
      render: { aboveEditable: BlockContextMenu },
    }),
  ],
});
import { BlockMenuPlugin } from '@platejs/selection/react';
import { createPlateEditor } from 'platejs/react';
 
import { BlockSelectionKit } from '@/components/editor/plugins/block-selection-kit';
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
export const editor = createPlateEditor({
  plugins: [
    ...BlockSelectionKit,
    BlockMenuPlugin.configure({
      render: { aboveEditable: BlockContextMenu },
    }),
  ],
});

Use this lower-level shape only when you are replacing both registry kits.

import {
  BlockMenuPlugin,
  BlockSelectionPlugin,
} from '@platejs/selection/react';
import { createPlateEditor } from 'platejs/react';
 
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
export const editor = createPlateEditor({
  plugins: [
    BlockSelectionPlugin.configure({
      options: {
        enableContextMenu: true,
      },
    }),
    BlockMenuPlugin.configure({
      render: { aboveEditable: BlockContextMenu },
    }),
  ],
});
import {
  BlockMenuPlugin,
  BlockSelectionPlugin,
} from '@platejs/selection/react';
import { createPlateEditor } from 'platejs/react';
 
import { BlockContextMenu } from '@/components/ui/block-context-menu';
 
export const editor = createPlateEditor({
  plugins: [
    BlockSelectionPlugin.configure({
      options: {
        enableContextMenu: true,
      },
    }),
    BlockMenuPlugin.configure({
      render: { aboveEditable: BlockContextMenu },
    }),
  ],
});

Context Menu Rules

CaseBehavior
Right-click on a selectable blockSelects that block, then opens the context menu at the pointer position.
Shift + right-clickAdds the block to the current block selection.
Right-click inside a focused text selectionLeaves the browser context menu unless the block is already selected, void, or explicitly opted in.
Left-click while the menu is openPrevents the click and hides the menu.
Touch deviceRenders children without the context-menu wrapper.
Read-only editorPrevents the context menu.

Disable the Plate context menu for 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>

Force it open from a focused block with data-plate-open-context-menu="true" when the block should bypass the focused-selection guard.

BlockContextMenu acts on the current block selection.

ActionSource
Ask AIOpens AIChatPlugin after the menu closes.
DeleteCalls editor.getTransforms(BlockSelectionPlugin).blockSelection.removeNodes().
DuplicateCalls editor.getTransforms(BlockSelectionPlugin).blockSelection.duplicate().
Turn intoCalls the registry setBlockType helper for paragraph, headings, blockquote, and code drawing.
Indent / OutdentCalls blockSelection.setIndent(1) or blockSelection.setIndent(-1).
AlignCalls blockSelection.setNodes({ align }).

The menu focuses block selection after close so keyboard selection remains active.

API Reference

APIPackageUse
BLOCK_CONTEXT_MENU_ID@platejs/selection/reactBuilt-in open id for the registry context menu.
BlockMenuPlugin@platejs/selection/reactMenu state plugin with openId and position options.
api.blockMenu.hide()@platejs/selection/reactCloses the menu and moves its stored position offscreen.
api.blockMenu.show(id, position?)@platejs/selection/reactOpens a menu by id and optionally sets pointer coordinates.
api.blockMenu.showContextMenu(blockId, position)@platejs/selection/reactSelects one block by id, then opens the context menu at the pointer coordinates.
BlockSelectionPlugin.options.enableContextMenu@platejs/selection/reactEnables block selection from right-click events.
api.blockSelection.addOnContextMenu@platejs/selection/reactShared right-click handler used by selectable block node props.
BlockContextMenuRegistry UIDefault context menu component used by BlockMenuKit.