Skip to content

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 + button
  • BlockContextMenu - Delete / Duplicate / Turn into / Colors / Copy link
  • SlashCommand - type / to open a filtered insert popup
  • SmartPaste - preserves block formatting when pasting at inline positions
  • KeyboardReorder - Mod-Shift-ArrowUp/Down moves the current top-level block
  • FloatingMenu - block-insert menu (with requireExplicitTrigger for 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.

Terminal window
pnpm add @domternal/extension-block-menu

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.


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 the FloatingMenu (when configured)
  • Auto-scroll - the nearest scrollable ancestor scrolls when dragging near the viewport edge
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
})
OptionTypeDefaultDescription
hideDelaynumber200Ms before hiding the handle after the mouse leaves the editor
disableDragbooleanfalseDisable drag-to-reorder while still showing the plus/drag buttons
autoScrollbooleantrueAuto-scroll the nearest scrollable ancestor when dragging near viewport edges
autoScrollThresholdnumber48Px distance from top/bottom edge that triggers auto-scroll
autoScrollMaxSpeednumber18Peak scroll speed in px per animation frame
nestedboolean | NestedConfigfalseEnable nested-block resolution (list items, task items)
nestThresholdnumber28Px from left edge of a list item that commits to nested-child drop. 0 disables nested-drop.
dropIndicatorbooleantrueShow custom drop indicator that mirrors exactly where the drop lands

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:

FieldTypeDefaultPurpose
allowedNodesstring[]['listItem', 'taskItem']Node type names treated as nested drag targets
allowedContainersstring[][]Restrict to nodes that have one of these ancestors (empty = unrestricted)
promoteOnEdgeboolean | GutterBiasPreset | Partial<GutterBiasConfig>falsePenalise deep matches near edges so shallower ancestors win
matchersBlockMatcher[][]Append custom block matchers
defaultMatchersbooleantrueKeep the four built-in matchers (firstChildOfListItem, listContainerSkip, tableInternals, inlineNodes)
EventDirectionDetailWhen
dm:block-context-menu-opendispatched on .dm-editor{ blockPos: number, anchorElement: HTMLElement }User clicks the drag handle (opens the context menu)
dm:dismiss-overlaysdispatched on .dm-editornullOpens or starts a drag (closes other overlays)
  • .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
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';

The contextual menu that opens when the user clicks the drag handle. Renders Delete / Duplicate / Turn into, plus optional Colors and Copy link sections.

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
})
OptionTypeDefaultDescription
turnIntoEnabledbooleantrueShow the “Turn into” section
turnIntoTargetsTurnIntoTarget[]DEFAULT_TURN_INTOBlock types offered by “Turn into”
copyLinkEnabledbooleantrueShow “Copy link” when UniqueID is loaded AND the block has an id attribute
onCopyLink(blockId, editor) => string#<id> appended to current pathname+searchBuild the URL written to the clipboard
blockColorEnabledbooleantrueShow Colors section when BlockColor is loaded and the block type is in its types list
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).

ItemIconConditionBehavior
DeletetrashAlwaysdeleteBlock(tr, pos)
DuplicatecopyHidden for horizontalRuleduplicateBlock(tr, pos, transformAttrs?)
Copy linklinkUniqueID loaded AND block has id AND copyLinkEnabledwriteToClipboard(onCopyLink(id, editor)) emits dm:copy-link-success or dm:copy-link-error
Colors (2 rows)swatchesBlockColor loaded AND block type in typessetBlockBgColor / setBlockTextColor
Turn into (per target)per targetturnIntoEnabled AND target compatibleturnIntoBlock(...) or turnIntoWrapper(...) for command targets
EventDirectionDetailWhen
dm:copy-link-successdispatched{ url: string, blockId: string }Copy-link succeeded
dm:copy-link-errordispatched{ url: string, blockId: string }Copy-link failed (Clipboard API rejected)
dm:block-context-menu-openlistened{ blockPos, anchorElement }Opens the menu
dm:dismiss-overlayslistened + dispatchednullCloses on outside trigger; dispatches on own open
  • 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-pressed state
  • Focus returns to the editor view after action completes

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.

  • .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
import {
BlockContextMenu,
createBlockContextMenuPlugin,
blockContextMenuPluginKey,
} from '@domternal/extension-block-menu';
import type {
BlockContextMenuOptions,
CreateBlockContextMenuPluginOptions,
TurnIntoTarget,
WrapperCommand,
} from '@domternal/extension-block-menu';

Type / to open a filtered insert popup. Items come from extensions’ addFloatingMenuItems hook (same set as the FloatingMenu).

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
})
OptionTypeDefaultDescription
charstring'/'The trigger character
itemsFloatingMenuItemsOverrideundefinedOverride the items list. Array replaces defaults; function transforms collected defaults
render() => SlashCommandRenderercreateSlashSuggestionRenderer()Factory returning popup render callbacks
invalidNodesstring[]['codeBlock']Node types where slash should NOT activate

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.

filterSlashItems(items, query) ranks matches in this priority:

  1. Exact label prefix (case-insensitive)
  2. Label substring match
  3. Keyword match (preserving original keyword index for stable ranking)

Stable within the same rank.

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.

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.

import { dismissSlashCommand } from '@domternal/extension-block-menu';
dismissSlashCommand(editor.view);
import {
SlashCommand,
createSlashCommandPlugin,
slashCommandPluginKey,
dismissSlashCommand,
filterSlashItems,
createSlashSuggestionRenderer,
} from '@domternal/extension-block-menu';
import type {
SlashCommandOptions,
SlashCommandProps,
SlashCommandRenderer,
SlashCommandPluginState,
CreateSlashCommandPluginOptions,
} from '@domternal/extension-block-menu';

Adds Mod-Shift-ArrowUp and Mod-Shift-ArrowDown to move the current top-level block up or down.

  1. Finds the top-level block containing the selection
  2. Validates - boundary check (cannot move first block up, last block down)
  3. Preserves selection offset relative to the moved block start
  4. Calls moveBlock(tr, sourcePos, targetPos) with the appropriate target
  5. Restores selection inside the moved block at the same offset
  6. Dispatches with scrollIntoView() so the moved block stays visible

No configurable options.

import { KeyboardReorder } from '@domternal/extension-block-menu';

Preserves block formatting when pasting at inline positions and routes pastes through Notion-style rules.

SmartPaste.configure({
enabled: true, // disable to fall back to PM's default paste handling
})
CaseConditionAction
List slice into listSlice is a single list AND caret has a list ancestorAdapt items via convertListItemForParent, merge as siblings
Trailing Shift+EnterParent has a hardBreak at cursorTrim hardBreak, insert slice as sibling after parent
Empty parent paragraphparentSize === 0 AND not in list-item labelReplace parent with slice; if in label, insert after
Caret at startoffset === 0Insert slice at parent start
Caret at endoffset === parentSizeInsert slice at parent end
Caret in middle0 < offset < parentSizeSplit parent, insert slice at boundary
Range selectionSelection not emptyDelete first, then route through strategies above
PM defaultSlice is all paragraphs OR single block same type as parentReturn false (let PM handle natively)
import { SmartPaste } from '@domternal/extension-block-menu';
import type { SmartPasteOptions } from '@domternal/extension-block-menu';

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

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,
})

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-mode CSS class
  • Configuration cookbook (palette overrides, custom slash items, nested config)
  • Toast handling for Copy link success/error events

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.

OverlayDispatches onListens for
BlockContextMenuOpenClose on dm:dismiss-overlays
SlashCommandOpenClose on dm:dismiss-overlays
FloatingMenuOpen (explicit trigger)Close on dm:dismiss-overlays
BubbleMenu (core)OpenClose on dm:dismiss-overlays
NotionColorPicker (core)OpenClose 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
});

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';

@domternal/extension-block-menu - GitHub