Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

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}`
});
PropType
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.

Type(block: Block) => string
menu?Snippet<[BlockMenuController]>

Replaces the menu's markup. See Custom markup below.

TypeSnippet<[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:

  1. A “Search actions…” field, focused.
  2. A heading naming the block’s kind, such as “Heading 2”.
  3. 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:

PropType
block?Block | null

The block whose grip opened the menu.

TypeBlock | null
blocks?Block[]

The blocks the actions apply to, in document order: the block selection when it holds block, else block alone.

TypeBlock[]
actions?BlockMenuAction[]

The rows that apply, filtered by query: { id, label, icon, hint?, danger?, submenu?, run? }. Turn into has submenu: true and no run.

TypeBlockMenuAction[]
kinds?KindRow[]

The kinds the block may turn into while keeping its content.

TypeKindRow[]
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.

TypeKindRow | undefined
turnInto(kind)?(kind: KindRow) => void

Convert the blocks.

Type(kind: KindRow) => void
duplicate(block)?(block: Block) => void

Insert a copy after the block (block.duplicateBlock(): fresh ids, one undo step).

Type(block: Block) => void
duplicateAll(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.

Type(blocks: Block[]) => void
move(direction)?('up' | 'down') => void

Move the blocks one step.

Type('up' | 'down') => void
remove()?() => void

Delete the blocks; unselected children take their parent's place.

Type() => void
copyLink()?() => Promise<void>

Write linkTo(block) to the clipboard.

Type() => Promise<void>
close()?(restoreCaret?: boolean) => void

Close the menu; the caret returns to the block (several blocks stay selected) unless restoreCaret is false.

Type(restoreCaret?: boolean) => void
query?string

The search text. Writable: actions filter by it.

Typestring
selectedIndex?number

The keyboard's row. Writable.

Typenumber
flyout?boolean

Whether the Turn into flyout is open. Writable.

Typeboolean

Every 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.

Was this page helpful?