Block menu
The block menu plugin opens Notion's block menu when you click a block handle's grip, with search, Turn into, Duplicate, Move and Delete, and binds Mod+D to duplicate.
blockMenuPlugin opens a menu when you click a block handle’s ⋮⋮ grip: a search field, Turn into, Duplicate, Move up, Move down and Delete, as in Notion. It also binds Mod + D to duplicate a block.
<script lang="ts">
import { Edytor, blockMenuPlugin } from 'edytor';
</script>
<Edytor plugins={[blockMenuPlugin]} />
The menu opens from block handles, which <Edytor> adds by default. Pass no onActivate to them: a handle with an onActivate callback calls it instead, and the menu never opens.
Options
blockMenuPlugin has no block link and the built-in markup. createBlockMenuPlugin(options) builds one with options:
import { createBlockMenuPlugin } from 'edytor';
const blockMenu = createBlockMenuPlugin({
linkTo: (block) => `${location.origin}${location.pathname}#block-${block.id}`
});
linkTo?(block: Block) => string
A URL for the block. Adds a 'Copy link to block' row that writes it to the clipboard. Without it, the row is hidden.
(block: Block) => stringmenu?Snippet<[BlockMenuController]>
Replaces the menu's markup. See Custom markup below.
Snippet<[BlockMenuController]>The options type is BlockMenuOptions.
What it shows
The menu opens beside the grip, top-aligned 8px to its right (to its left when the right has no room, flipped up when the space below is short), follows scrolling and resizing, and closes when the grip leaves the view. From top to bottom:
- A “Search actions…” field, focused.
- A heading naming the block’s kind, such as “Heading 2”.
- The actions:
| Row | Hint | Does |
|---|---|---|
| Turn into › | Opens a flyout of the kinds that keep content, with ✓ on the current one | |
| Copy link to block | Writes linkTo(block) to the clipboard. Only with linkTo |
|
| Duplicate | Mod + D | Inserts a copy after the block, with fresh ids for it, its children and its atoms |
| Move up | Mod + Shift + ↑ | Moves the block one step up |
| Move down | Mod + Shift + ↓ | Moves the block one step down |
| Delete | Del | Removes the block; its children take its place |
A row that cannot apply is hidden: Turn into for a block that is not convertible (void, island or inside one), Move up or Move down when edytor.canMoveBlocks refuses the step. The hints show the matching shortcuts; Mod + Shift + ↑/↓ need the arrow move plugin.
Typing in the field filters the actions by label, and lists the kinds whose label or keywords match under a “Turn into” heading: type head and pick “Heading 2” without opening the flyout. The query matches as in the slash menu: each word starts a word of the label or of a keyword, hyphens ignored, so todo and to do find “To-do list”, move finds Move up and Move down, and a letter inside a word (o in “Move”) matches nothing. With nothing left, the menu shows “No results”.
Keys
While the menu is open, its search field takes the keys:
| Keys | Action |
|---|---|
| ↑, ↓ | Move the highlight (wraps around); in the flyout, walk its kinds |
| Home, End | Highlight the first or last row |
| → | Open the Turn into flyout from its row |
| ← | Close the flyout |
| Enter | Run the highlighted row, open the flyout from Turn into, or convert to the flyout’s highlighted kind |
| Delete | Delete the block, or the selected blocks (with an empty search) |
| Escape | Close the flyout, then the menu |
Hovering a row highlights it, in the menu and in the flyout.
Behavior
- The handle click selects the block, and it stays selected while the menu is open.
- A click on the grip of a block inside a block selection keeps the selection, and the actions apply to every selected block, as in Notion: Turn into converts them all, Duplicate copies each one after itself, Move up and Move down move the group, and Delete removes them, each as one undo step. After Turn into, Move and Escape the blocks stay selected; after Delete the caret goes to the nearest text after the first of them. Copy link to block is hidden for several blocks.
- Every action closes the menu and returns a text caret: at the start of the converted or moved block, of the copy after Duplicate, or of the nearest text after the deleted block after Delete: its first child when it had children (they take its place), else the next block, else the end of the nearest text before it; void blocks such as dividers are skipped. When a plugin refuses the conversion or the deletion, the block stays and the caret returns to its start. Escape also returns the caret to the block. A block that holds no text, such as a divider or an image, stays selected instead after Move, Escape or a refusal, and Duplicate selects its copy.
- A pointer press outside the menu and the handles closes it without moving the caret, and so does the editor turning readonly.
- Each action is one undo step, separate from the typing before it however soon it follows, through the same commands as the rest of the editor (
convertToKind,duplicateBlock,edytor.moveBlocks,removeBlock), so plugins can refuse them.
Mod + D duplicates every selected block, in document order and as one undo step, each copy after its block, and selects the copies. Without a block selection it duplicates the block holding the caret and puts the caret in the copy. It is claimed only for a block that can move, and never in a readonly editor.
How it opens
When a handle is clicked and no onActivate is set, block handles dispatch a DOM event on the editor’s root:
import { BLOCK_ACTIVATE_EVENT, type BlockActivation } from 'edytor';
// 'edytor-block-activate', detail: { block, anchor }
block is the clicked block and anchor the grip element. The block menu listens for it. A plugin of your own can listen for the same event in onEdytorAttached to open a different menu, or to add behavior next to this one.
Placement and styling
The menu mounts in the editor’s overlay, in a position: fixed host with the attribute data-edytor-block-menu-host and z-index: 70. Its markup and styles are built in: a 265px panel with line icons for the built-in kinds and actions, the kind flyout beside it, and a 140ms fade and scale-in. The panel has data-edytor-block-menu and data-testid="block-menu", and each action row data-testid="block-menu-<id>", with ids turn, link, duplicate, up, down and delete.
Custom markup
A menu snippet replaces the panel. It renders while controller.isOpen, in the same host, placed beside the grip by the element marked data-edytor-block-menu (else your snippet’s first element). The plugin keeps opening it on a grip click, closing it on a press outside, placing it, and Mod + D. The keys in the table above belong to the built-in search field: a custom menu handles its own keys.
The snippet receives the BlockMenuController:
block?Block | null
The block whose grip opened the menu.
Block | nullblocks?Block[]
The blocks the actions apply to, in document order: the block selection when it holds block, else block alone.
Block[]actions?BlockMenuAction[]
The rows that apply, filtered by query: { id, label, icon, hint?, danger?, submenu?, run? }. Turn into has submenu: true and no run.
BlockMenuAction[]kinds?KindRow[]
The kinds the block may turn into while keeping its content.
KindRow[]currentKind?KindRow | undefined
The row naming the block: of its kind's rows, the one whose preset data shares the most values with the block's (the first on a tie), so a checked to-do is still To-do list.
KindRow | undefinedturnInto(kind)?(kind: KindRow) => void
Convert the blocks.
(kind: KindRow) => voidduplicate(block)?(block: Block) => void
Insert a copy after the block (block.duplicateBlock(): fresh ids, one undo step).
(block: Block) => voidduplicateAll(blocks)?(blocks: Block[]) => void
Copy each block after itself as one undo step, and select the copies. The Duplicate action calls it for several blocks.
(blocks: Block[]) => voidmove(direction)?('up' | 'down') => void
Move the blocks one step.
('up' | 'down') => voidremove()?() => void
Delete the blocks; unselected children take their parent's place.
() => voidcopyLink()?() => Promise<void>
Write linkTo(block) to the clipboard.
() => Promise<void>close()?(restoreCaret?: boolean) => void
Close the menu; the caret returns to the block (several blocks stay selected) unless restoreCaret is false.
(restoreCaret?: boolean) => voidquery?string
The search text. Writable: actions filter by it.
stringselectedIndex?number
The keyboard's row. Writable.
numberflyout?boolean
Whether the Turn into flyout is open. Writable.
booleanEvery action closes the menu and returns the caret, as in the built-in menu: it replaces the block selection the grip click left, and the projector draws it after the flush.
<script lang="ts">
import { Edytor, createBlockMenuPlugin, type BlockMenuController } from 'edytor';
const plugins = [createBlockMenuPlugin({ menu })];
</script>
{#snippet menu(controller: BlockMenuController)}
<div class="menu" data-edytor-block-menu role="menu">
{#each controller.actions.filter((action) => action.run) as action (action.id)}
<button role="menuitem" onclick={() => action.run?.()}>{action.label}</button>
{/each}
{#each controller.kinds as kind (kind.id)}
<button role="menuitem" onclick={() => controller.turnInto(kind)}>{kind.label}</button>
{/each}
</div>
{/snippet}
<Edytor {plugins} />
To open a different menu without this plugin, listen for edytor-block-activate, or pass onActivate to the block handles (see Building a block menu). Menus and handles shows the other snippets.