Block Menu
The @domternal/extension-block-menu package adds Notion-style block UX layered onto any Domternal editor. It ships five coordinated extensions plus the FloatingMenu block-insert popup:
BlockHandle- hover gutter with drag handle and+buttonBlockContextMenu- Delete / Duplicate / Turn into / Colors / Copy linkSlashCommand- type/to open a filtered insert popupSmartPaste- preserves block formatting when pasting at inline positionsKeyboardReorder-Mod-Shift-ArrowUp/Downmoves the current top-level blockFloatingMenu- block-insert menu (withrequireExplicitTriggerfor Notion mode)
All five cooperate via custom DOM events (dm:dismiss-overlays, dm:block-context-menu-open, dm:copy-link-success, dm:copy-link-error) so opening one closes the others.
Installation
Section titled “Installation”pnpm add @domternal/extension-block-menunpm install @domternal/extension-block-menuyarn add @domternal/extension-block-menuQuickstart
Section titled “Quickstart”Add the extensions you want to your editor’s extension list. Most apps use the full set together for the Notion experience:
import { Editor, StarterKit } from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu,} from '@domternal/extension-block-menu';import '@domternal/theme';
const editor = new Editor({ element: document.getElementById('editor')!, extensions: [ StarterKit, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu.configure({ element: document.getElementById('floating-menu')!, requireExplicitTrigger: true, }), ],});Add the CSS class dm-notion-mode on your .dm-editor wrapper to opt into the Notion-style layout (centered content, side gutter, larger font). See the Notion Mode guide for the full setup.
BlockHandle
Section titled “BlockHandle”The hover gutter that appears next to the block under the cursor. Provides:
- Drag handle - drag-to-reorder blocks with a custom drop indicator (sibling or nested-child placement)
+button - inserts an empty paragraph below and opens theFloatingMenu(when configured)- Auto-scroll - the nearest scrollable ancestor scrolls when dragging near the viewport edge
Options
Section titled “Options”BlockHandle.configure({ hideDelay: 200, // ms before hiding handle after mouse leaves editor disableDrag: false, // disable drag while keeping plus/drag buttons autoScroll: true, // auto-scroll during drag near viewport edges autoScrollThreshold: 48, // px from edge that triggers auto-scroll autoScrollMaxSpeed: 18, // peak scroll speed in px/frame nested: false, // false | true | NestedConfig nestThreshold: 28, // px from left edge to commit nested-child drop (0 disables) dropIndicator: true, // show custom drop indicator instead of prosemirror-dropcursor})| Option | Type | Default | Description |
|---|---|---|---|
hideDelay | number | 200 | Ms before hiding the handle after the mouse leaves the editor |
disableDrag | boolean | false | Disable drag-to-reorder while still showing the plus/drag buttons |
autoScroll | boolean | true | Auto-scroll the nearest scrollable ancestor when dragging near viewport edges |
autoScrollThreshold | number | 48 | Px distance from top/bottom edge that triggers auto-scroll |
autoScrollMaxSpeed | number | 18 | Peak scroll speed in px per animation frame |
nested | boolean | NestedConfig | false | Enable nested-block resolution (list items, task items) |
nestThreshold | number | 28 | Px from left edge of a list item that commits to nested-child drop. 0 disables nested-drop. |
dropIndicator | boolean | true | Show custom drop indicator that mirrors exactly where the drop lands |
Nested mode
Section titled “Nested mode”When nested: true (or a NestedConfig object), the handle resolves to list items and task items individually instead of always the top-level block. Combined with nestThreshold, dropping past the left padding of a list item commits to nested-child mode (the dragged block becomes the last child of that item); dropping closer to the marker stays in sibling mode (drops adjacent to the item).
BlockHandle.configure({ nested: { allowedNodes: ['listItem', 'taskItem'], // default allowedContainers: [], // restrict to descendants of these (empty = unrestricted) promoteOnEdge: 'left', // shallower ancestor wins near edges matchers: [], // custom block matchers defaultMatchers: true, // keep the 4 built-in matchers },})NestedConfig fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
allowedNodes | string[] | ['listItem', 'taskItem'] | Node type names treated as nested drag targets |
allowedContainers | string[] | [] | Restrict to nodes that have one of these ancestors (empty = unrestricted) |
promoteOnEdge | boolean | GutterBiasPreset | Partial<GutterBiasConfig> | false | Penalise deep matches near edges so shallower ancestors win |
matchers | BlockMatcher[] | [] | Append custom block matchers |
defaultMatchers | boolean | true | Keep the four built-in matchers (firstChildOfListItem, listContainerSkip, tableInternals, inlineNodes) |
DOM events
Section titled “DOM events”| Event | Direction | Detail | When |
|---|---|---|---|
dm:block-context-menu-open | dispatched on .dm-editor | { blockPos: number, anchorElement: HTMLElement } | User clicks the drag handle (opens the context menu) |
dm:dismiss-overlays | dispatched on .dm-editor | null | Opens or starts a drag (closes other overlays) |
CSS classes
Section titled “CSS classes”.dm-editor--has-block-handle- applied to the editor when BlockHandle is active (reserves gutter space).dm-block-handle- gutter handle wrapper (absolutely positioned).dm-block-handle-btn- individual buttons inside the handle (⋮⋮,+).dm-block-context-active- decoration on the target block while its context menu is open
Exports
Section titled “Exports”import { BlockHandle, createBlockHandlePlugin, blockHandlePluginKey, DEFAULT_NESTED_NODES, DEFAULT_BLOCK_MATCHERS,} from '@domternal/extension-block-menu';import type { BlockHandleOptions, BlockHandlePluginState, CreateBlockHandlePluginOptions, NestedConfig, BlockMatcher, BlockCandidate, MatchVerdict,} from '@domternal/extension-block-menu';BlockContextMenu
Section titled “BlockContextMenu”The contextual menu that opens when the user clicks the drag handle. Renders Delete / Duplicate / Turn into, plus optional Colors and Copy link sections.
Options
Section titled “Options”BlockContextMenu.configure({ turnIntoEnabled: true, // show "Turn into" section turnIntoTargets: DEFAULT_TURN_INTO, // override the list of turn-into targets copyLinkEnabled: true, // show "Copy link" when UniqueID + id attr present onCopyLink: (id, editor) => { // build the URL written to clipboard const { pathname, search } = window.location; return `${pathname}${search}#${id}`; }, blockColorEnabled: true, // show Colors section when BlockColor + block type in types})| Option | Type | Default | Description |
|---|---|---|---|
turnIntoEnabled | boolean | true | Show the “Turn into” section |
turnIntoTargets | TurnIntoTarget[] | DEFAULT_TURN_INTO | Block types offered by “Turn into” |
copyLinkEnabled | boolean | true | Show “Copy link” when UniqueID is loaded AND the block has an id attribute |
onCopyLink | (blockId, editor) => string | #<id> appended to current pathname+search | Build the URL written to the clipboard |
blockColorEnabled | boolean | true | Show Colors section when BlockColor is loaded and the block type is in its types list |
Default Turn-into targets
Section titled “Default Turn-into targets”const DEFAULT_TURN_INTO: TurnIntoTarget[] = [ { label: 'Paragraph', icon: 'textT', nodeType: 'paragraph' }, { label: 'Heading 1', icon: 'textHOne', nodeType: 'heading', attrs: { level: 1 } }, { label: 'Heading 2', icon: 'textHTwo', nodeType: 'heading', attrs: { level: 2 } }, { label: 'Heading 3', icon: 'textHThree', nodeType: 'heading', attrs: { level: 3 } }, { label: 'Bullet list', icon: 'listBullets', nodeType: 'bulletList', command: 'toggleBulletList' }, { label: 'Ordered list', icon: 'listNumbers', nodeType: 'orderedList', command: 'toggleOrderedList' }, { label: 'To-do list', icon: 'listChecks', nodeType: 'taskList', command: 'toggleTaskList' }, { label: 'Quote', icon: 'quotes', nodeType: 'blockquote', command: 'toggleBlockquote' }, { label: 'Code block', icon: 'codeBlock', nodeType: 'codeBlock' },];TurnIntoTarget fields: label, icon (resolved against defaultIcons), nodeType (schema name), optional attrs, optional command (for wrapper targets like lists - routes through turnIntoWrapper instead of setBlockType).
Menu items
Section titled “Menu items”| Item | Icon | Condition | Behavior |
|---|---|---|---|
| Delete | trash | Always | deleteBlock(tr, pos) |
| Duplicate | copy | Hidden for horizontalRule | duplicateBlock(tr, pos, transformAttrs?) |
| Copy link | link | UniqueID loaded AND block has id AND copyLinkEnabled | writeToClipboard(onCopyLink(id, editor)) emits dm:copy-link-success or dm:copy-link-error |
| Colors (2 rows) | swatches | BlockColor loaded AND block type in types | setBlockBgColor / setBlockTextColor |
| Turn into (per target) | per target | turnIntoEnabled AND target compatible | turnIntoBlock(...) or turnIntoWrapper(...) for command targets |
DOM events
Section titled “DOM events”| Event | Direction | Detail | When |
|---|---|---|---|
dm:copy-link-success | dispatched | { url: string, blockId: string } | Copy-link succeeded |
dm:copy-link-error | dispatched | { url: string, blockId: string } | Copy-link failed (Clipboard API rejected) |
dm:block-context-menu-open | listened | { blockPos, anchorElement } | Opens the menu |
dm:dismiss-overlays | listened + dispatched | null | Closes on outside trigger; dispatches on own open |
Accessibility
Section titled “Accessibility”role="menu"on the root,role="menuitem"on every button- Roving tabindex - only the focused item is in Tab order
- Arrow Up/Down cycle, Home/End endpoints, Escape closes
- Color swatches have
aria-pressedstate - Focus returns to the editor view after action completes
writeToClipboard utility
Section titled “writeToClipboard utility”BlockContextMenu’s Copy link uses a small helper exported from @domternal/core:
import { writeToClipboard } from '@domternal/core';
await writeToClipboard('hello'); // resolves to true (Clipboard API) or false (execCommand fallback rejected)The function tries the async Clipboard API first and falls back to document.execCommand('copy') when the API is unavailable or denied. It never throws.
CSS classes
Section titled “CSS classes”.dm-block-context-menu- root container.dm-block-context-menu-group- grouping (Primary, Colors, Turn into).dm-block-context-menu-group-label- section heading (“Colors”, “Turn into”).dm-block-context-menu-item- regular menu item.dm-block-context-menu-item-icon- icon span.dm-block-context-menu-item-label- text label.dm-block-color-swatch,.dm-block-color-swatch--bg,.dm-block-color-swatch--text- color swatches.dm-block-color-row- row container for swatches.dm-block-context-active- decoration on the target block
Exports
Section titled “Exports”import { BlockContextMenu, createBlockContextMenuPlugin, blockContextMenuPluginKey,} from '@domternal/extension-block-menu';import type { BlockContextMenuOptions, CreateBlockContextMenuPluginOptions, TurnIntoTarget, WrapperCommand,} from '@domternal/extension-block-menu';SlashCommand
Section titled “SlashCommand”Type / to open a filtered insert popup. Items come from extensions’ addFloatingMenuItems hook (same set as the FloatingMenu).
Options
Section titled “Options”SlashCommand.configure({ char: '/', // trigger character items: undefined, // override or transform the items list render: () => createSlashSuggestionRenderer(), // popup renderer factory invalidNodes: ['codeBlock'], // node types where slash should NOT activate})| Option | Type | Default | Description |
|---|---|---|---|
char | string | '/' | The trigger character |
items | FloatingMenuItemsOverride | undefined | Override the items list. Array replaces defaults; function transforms collected defaults |
render | () => SlashCommandRenderer | createSlashSuggestionRenderer() | Factory returning popup render callbacks |
invalidNodes | string[] | ['codeBlock'] | Node types where slash should NOT activate |
Trigger detection
Section titled “Trigger detection”The plugin activates ONLY on a real user typing event. It detects this by checking that the transaction is a single-character ReplaceStep inserting the trigger char. This excludes:
- Paste (multi-step)
- Bulk inserts via commands
- Undo/redo replays
- Pure selection changes that happen to land after a
/
After activation, the plugin tracks query as the user types more characters. The query is bounded by validation: not inside invalidNodes, no whitespace immediately before the /, no newlines or tabs in the query.
Filter ranking
Section titled “Filter ranking”filterSlashItems(items, query) ranks matches in this priority:
- Exact label prefix (case-insensitive)
- Label substring match
- Keyword match (preserving original keyword index for stable ranking)
Stable within the same rank.
Renderer contract
Section titled “Renderer contract”If you want a custom popup, provide a render factory returning these callbacks:
interface SlashCommandRenderer { onStart: (props: SlashCommandProps) => void; onUpdate: (props: SlashCommandProps) => void; onExit: () => void; onKeyDown: (event: KeyboardEvent) => boolean; // return true to consume}
interface SlashCommandProps { editor: Editor; query: string; range: { from: number; to: number }; items: FloatingMenuItem[]; command: (item: FloatingMenuItem) => void; clientRect: () => DOMRect | null; element: HTMLElement;}The default createSlashSuggestionRenderer() builds a popup positioned via positionFloatingOnce, with arrow-key navigation, Enter/Tab to select, Escape to dismiss.
Cross-overlay coordination
Section titled “Cross-overlay coordination”Slash dispatches dm:dismiss-overlays on open (closes BlockContextMenu, FloatingMenu, NotionColorPicker). It also listens for dm:dismiss-overlays to close itself when other overlays open.
Programmatic dismiss
Section titled “Programmatic dismiss”import { dismissSlashCommand } from '@domternal/extension-block-menu';
dismissSlashCommand(editor.view);Exports
Section titled “Exports”import { SlashCommand, createSlashCommandPlugin, slashCommandPluginKey, dismissSlashCommand, filterSlashItems, createSlashSuggestionRenderer,} from '@domternal/extension-block-menu';import type { SlashCommandOptions, SlashCommandProps, SlashCommandRenderer, SlashCommandPluginState, CreateSlashCommandPluginOptions,} from '@domternal/extension-block-menu';KeyboardReorder
Section titled “KeyboardReorder”Adds Mod-Shift-ArrowUp and Mod-Shift-ArrowDown to move the current top-level block up or down.
Behavior
Section titled “Behavior”- Finds the top-level block containing the selection
- Validates - boundary check (cannot move first block up, last block down)
- Preserves selection offset relative to the moved block start
- Calls
moveBlock(tr, sourcePos, targetPos)with the appropriate target - Restores selection inside the moved block at the same offset
- Dispatches with
scrollIntoView()so the moved block stays visible
No configurable options.
Exports
Section titled “Exports”import { KeyboardReorder } from '@domternal/extension-block-menu';SmartPaste
Section titled “SmartPaste”Preserves block formatting when pasting at inline positions and routes pastes through Notion-style rules.
Options
Section titled “Options”SmartPaste.configure({ enabled: true, // disable to fall back to PM's default paste handling})Behavior matrix
Section titled “Behavior matrix”| Case | Condition | Action |
|---|---|---|
| List slice into list | Slice is a single list AND caret has a list ancestor | Adapt items via convertListItemForParent, merge as siblings |
| Trailing Shift+Enter | Parent has a hardBreak at cursor | Trim hardBreak, insert slice as sibling after parent |
| Empty parent paragraph | parentSize === 0 AND not in list-item label | Replace parent with slice; if in label, insert after |
| Caret at start | offset === 0 | Insert slice at parent start |
| Caret at end | offset === parentSize | Insert slice at parent end |
| Caret in middle | 0 < offset < parentSize | Split parent, insert slice at boundary |
| Range selection | Selection not empty | Delete first, then route through strategies above |
| PM default | Slice is all paragraphs OR single block same type as parent | Return false (let PM handle natively) |
Exports
Section titled “Exports”import { SmartPaste } from '@domternal/extension-block-menu';import type { SmartPasteOptions } from '@domternal/extension-block-menu';FloatingMenu
Section titled “FloatingMenu”The block-insert popup that appears on empty paragraphs (or only on explicit trigger, in Notion mode). Documented in detail on its own page.
See Floating Menu for the full options table, FloatingMenuController API, items hook (addFloatingMenuItems), keyboard shortcuts (Alt-F10, Mod-/), and standalone plugin (createFloatingMenuPlugin).
Quick reference - requireExplicitTrigger
Section titled “Quick reference - requireExplicitTrigger”In Notion mode, set requireExplicitTrigger: true so the menu only opens when the BlockHandle’s + button calls showFloatingMenu(view). Empty paragraphs do not auto-show the menu - users open it via the + button or the slash command (/).
FloatingMenu.configure({ element: document.getElementById('floating-menu')!, requireExplicitTrigger: true,})Notion mode integration
Section titled “Notion mode integration”The five sub-extensions plus FloatingMenu form the Notion-mode stack. See the Notion Mode guide for the complete cross-framework setup, including:
- Required extensions checklist
- Per-framework wiring (Vanilla / Angular / React / Vue)
- The
.dm-notion-modeCSS class - Configuration cookbook (palette overrides, custom slash items, nested config)
- Toast handling for Copy link success/error events
Cross-overlay coordination
Section titled “Cross-overlay coordination”All overlays in this package cooperate via the dm:dismiss-overlays custom event dispatched on the .dm-editor element. When one opens, it dispatches the event to close the others.
| Overlay | Dispatches on | Listens for |
|---|---|---|
| BlockContextMenu | Open | Close on dm:dismiss-overlays |
| SlashCommand | Open | Close on dm:dismiss-overlays |
| FloatingMenu | Open (explicit trigger) | Close on dm:dismiss-overlays |
| BubbleMenu (core) | Open | Close on dm:dismiss-overlays |
| NotionColorPicker (core) | Open | Close on dm:dismiss-overlays |
Listen for these events to integrate your own overlays:
const editorEl = editor.view.dom.closest('.dm-editor');editorEl?.addEventListener('dm:dismiss-overlays', () => { // close your custom overlay});All exports
Section titled “All exports”import { // BlockHandle BlockHandle, createBlockHandlePlugin, blockHandlePluginKey, DEFAULT_NESTED_NODES, DEFAULT_BLOCK_MATCHERS, // BlockContextMenu BlockContextMenu, createBlockContextMenuPlugin, blockContextMenuPluginKey, // SlashCommand SlashCommand, createSlashCommandPlugin, slashCommandPluginKey, dismissSlashCommand, filterSlashItems, createSlashSuggestionRenderer, // KeyboardReorder KeyboardReorder, // SmartPaste SmartPaste, // FloatingMenu FloatingMenu, createFloatingMenuPlugin, floatingMenuPluginKey, showFloatingMenu, hideFloatingMenu,} from '@domternal/extension-block-menu';
import type { BlockHandleOptions, BlockHandlePluginState, CreateBlockHandlePluginOptions, NestedConfig, BlockMatcher, BlockCandidate, MatchVerdict, BlockContextMenuOptions, CreateBlockContextMenuPluginOptions, TurnIntoTarget, WrapperCommand, SlashCommandOptions, SlashCommandProps, SlashCommandRenderer, SlashCommandPluginState, CreateSlashCommandPluginOptions, SmartPasteOptions, FloatingMenuOptions, CreateFloatingMenuPluginOptions, FloatingMenuKeymap,} from '@domternal/extension-block-menu';Source
Section titled “Source”@domternal/extension-block-menu - GitHub