Block Controls
The @domternal/extension-block-controls 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.
When to use
Section titled “When to use”Use Block Controls when you need:
- A Notion-style block UX with drag handle,
+button, slash command, context menu, and keyboard reorder - Five coordinated extensions in one package (BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder)
- Cooperative overlays (opening one closes the others via the
dm:dismiss-overlaysevent)
Skip it if:
- Your editor is a simple bubble-menu + toolbar setup
- Your users expect Word-style editing (no drag handles, no slash command)
Installation
Section titled “Installation”pnpm add @domternal/extension-block-controlsnpm install @domternal/extension-block-controlsyarn add @domternal/extension-block-controlsQuickstart
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-controls';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. A read-only editor shows no handle at all, since every handle action edits the document, and a handle already on screen retracts as soon as setEditable(false) lands. Provides:
- Drag handle - drag-to-reorder blocks with a custom drop indicator. The indicator snaps to the nearest gap between blocks and the pointer’s horizontal position picks the nesting depth (drag right to nest, left to outdent)
+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, so a dragged block can land inside a list item as its child.
The drop indicator follows a gap-first model: as you drag, the line snaps to the nearest gap between blocks (vertical position), and the pointer’s horizontal position then picks the nesting depth among the ancestor levels available at that gap. Drag right to nest one level deeper, drag left to outdent across ancestor levels. Y and X dead-bands hold the current gap and level so the indicator does not flicker on a small wobble. A dragged block can land as the first, in-between, or last child of a list item, not only the last.
nestThreshold (default 28) is the px distance from a list item’s left edge past which the deepest “nest into this item” option is offered; dropping closer to the marker stays at sibling depth. Set it to 0 to disable nested-child drops entirely (every drop is a sibling).
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 | 'left' | 'right' | 'both' | 'none' | object | 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) |
promoteOnEdge accepts a preset or a partial config object. true is the same as 'left'; 'right' mirrors the gutter for RTL; 'both' biases on either side; false and 'none' disable the bias so the deepest match under the cursor wins. The object form is { edges?: ('left' | 'right' | 'top' | 'bottom')[]; threshold?: number; strength?: number }, defaulting to edges: ['left', 'top'], threshold: 12, strength: 500. The preset and config types are internal, so write the literals inline instead of importing a named type.
Dropping blocks into lists
Section titled “Dropping blocks into lists”The drop resolver keeps each block’s own type when you drag across list boundaries:
- Dropping a non-list block (paragraph, heading, quote, …) into a list keeps its type and splits the list around it, instead of wrapping it in a bullet. The indicator shows a full-width split line at the list’s parent column.
- Dropping a list item into a list of the other kind keeps its kind: a to-do dropped among bullets stays a to-do, splitting the bullet list rather than converting it.
- When the block separating two same-type lists is dragged out from between them, the lists rejoin into one (an ordered list renumbers continuously).
Experimental extension points
Section titled “Experimental extension points”Two options let a higher-priority plugin own drops and anchor handles inside side-by-side containers, so you can build Notion-style column layouts on top of the free handle.
dropZoneProviders: DropZoneProvider[] (top-level option, default []) lets a plugin claim pointer positions during a handle drag. Each provider runs on every drag pointer sample; the first one returning true claims that position, so BlockHandle hides its own drop indicator for that frame and treats a release there as a no-op. The claiming plugin then fully owns the indicator and the drop (draw it yourself and commit the move in a higher-priority plugin’s handleDrop, or a capture-phase document drop listener for gutter drops). Keep providers cheap and pure: geometry only, no DOM writes, no dispatch.
import type { DropZoneProvider, DropZoneQuery } from '@domternal/extension-block-controls';
const myZone: DropZoneProvider = (q: DropZoneQuery) => { // q.clientX / q.clientY: pointer in client coordinates // q.draggedFrom / q.draggedTo: dragged range, exclusive end - read the range, // not a single block, so a future multi-block drag keeps your claim working return isInsideMyColumnGutter(q.clientX, q.clientY);};
BlockHandle.configure({ dropZoneProviders: [myZone] });nested.anchorContainers: string[] (NestedConfig field, default []) names side-by-side layout container node types (e.g. a column node) whose children get their own per-block handle: the cursor’s horizontal position picks the container, hover resolution is scoped to that container’s subtree, and the handle renders against the container’s left edge instead of the editor gutter.
BlockHandle.configure({ nested: { anchorContainers: ['column'] }, dropZoneProviders: [myColumnDropZone], // claim drops back into the container});This only makes container children drag sources, and it applies to the default nested resolution only (ignored when promoteOnEdge is set or allowedNodes is empty).
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-handle-drag,.dm-block-handle-plus- per-button modifiers carried alongside the shared.dm-block-handle-btn. The theme uses them for the grab/grabbing cursor on the drag grip and the accent hover color on+.dm-block-drop-indicator- the custom drop indicator element, appended to.dm-editoronly whendropIndicator: true.dm-block-context-active- decoration on the target block while its context menu is open
The handle root and the drop indicator both carry data-dm-editor-ui, the marker that tells the bubble menu a mousedown landed on editor UI rather than outside the editor. Clicking a handle button still closes the bubble menu: that is the deliberate dm:dismiss-overlays broadcast in the table above. For .dm-block-handle-dragging, the class added to .dm-editor for the duration of a handle drag, see the note above.
Exports
Section titled “Exports”import { BlockHandle, createBlockHandlePlugin, blockHandlePluginKey, DEFAULT_NESTED_NODES, DEFAULT_BLOCK_MATCHERS,} from '@domternal/extension-block-controls';import type { BlockHandleOptions, BlockHandlePluginState, CreateBlockHandlePluginOptions, DropZoneProvider, DropZoneQuery, NestedConfig, BlockMatcher, BlockCandidate, MatchVerdict,} from '@domternal/extension-block-controls';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: 'turnIntoBulletList' }, { label: 'Ordered list', icon: 'listNumbers', nodeType: 'orderedList', command: 'turnIntoOrderedList' }, { label: 'To-do list', icon: 'listChecks', nodeType: 'taskList', command: 'turnIntoTaskList' }, { 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 |
transformAttrs is how Duplicate keeps ids unique. When UniqueID is loaded, the menu passes a mapper that replaces the block’s own id attribute (UniqueID’s configured attributeName, default id) with a freshly generated one on the copy and spreads every other attribute through unchanged, so the duplicate cannot steal the original’s #hash anchor or Copy link. Without UniqueID no mapper is passed and the copy keeps the source attrs verbatim. Ids on nested descendants are left to UniqueID’s duplicate-id rename pass.
Contributed items (addBlockMenuItems())
Section titled “Contributed items (addBlockMenuItems())”The table above is the built-in menu, not its limit. Any extension can add an
entry by declaring addBlockMenuItems(), the same shape addToolbarItems()
already uses:
import { Extension } from '@domternal/core';import type { BlockMenuItem } from '@domternal/extension-block-controls';
const Bookmark = Extension.create({ name: 'bookmark',
addBlockMenuItems(): BlockMenuItem[] { return [ { id: 'bookmark', label: 'Bookmark', icon: 'star', group: 'collaboration', order: 100, isEnabled: ({ blockId }) => blockId !== null || { disabled: true, reason: 'This block has no id yet' }, run: ({ editor, blockPos }) => { editor.commands.addBookmark({ pos: blockPos }); }, }, ]; },});Declare it, do not inject DOM into the open menu. This package builds the button
itself, which is what keeps every entry inside role="menuitem", the roving
tabindex, the arrow-key cycle and the mousedown preventDefault that holds
editor focus. An injected button gets all four wrong.
Item fields
| Field | Type | Notes |
|---|---|---|
id | string | Becomes the data-block-menu-item attribute, so it doubles as a test handle |
label | string | Rendered text |
icon | string | Key into the shared icon set. Register your SVG into defaultIcons first; an unknown key renders an empty icon span rather than throwing |
group | BlockMenuItemGroup | One of primary, colors, turnInto, collaboration |
order | number | Within the group, ascending. Defaults to 100; step by 10 to leave room |
isAvailable | (ctx) => boolean | Hides the item entirely |
isEnabled | (ctx) => true | { disabled: true, reason } | Renders the item inert with aria-disabled="true" and the reason as its title |
run | (ctx) => void | Called after the menu closes |
Groups are a closed set. They always render in the order primary, colors,
turnInto, collaboration, and contributed items form their own group container
placed after that group’s built-in content. So a contributor places an item
inside the menu but can never reorder the menu itself, and collaboration is
always last.
Hide or disable. Use isAvailable when the action can never apply to this
block. Use isEnabled when it normally could: an item that vanishes teaches the
reader nothing, while “Comment” greyed out with “This block is empty” does.
Context. { editor, blockPos, node, blockId }, where blockId is the
block’s UniqueID value, or null when UniqueID is not loaded or has not stamped
the block yet. It is resolved for you, so a contributor never needs to know how
the id attribute is configured.
Timing. The hook is called once when the plugin is created, so the item list
is fixed for the editor’s lifetime. isAvailable and isEnabled run on every
menu open, against the block being targeted.
Focus. run is called after the menu has closed, and the editor view is
deliberately not refocused, so an item that opens its own surface keeps the
focus. This differs from the built-in items.
An extension whose addBlockMenuItems() throws is skipped and the rest of the
menu still renders.
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 a built-in action completes. Contributed items run after the menu closes with no focus restore, so an item that opens its own surface keeps the focus
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--disabled- a contributed item that returned{ disabled: true, reason }fromisEnabled. The button also carriesaria-disabled="true"and the reason as itstitle. The shipped theme has no rule for this class yet, so style it yourself if you contribute disabled items.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-controls';import type { BlockContextMenuOptions, BlockMenuItem, BlockMenuItemContext, BlockMenuItemGroup, CreateBlockContextMenuPluginOptions, TurnIntoTarget, WrapperCommand,} from '@domternal/extension-block-controls';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-controls';
dismissSlashCommand(editor.view);Exports
Section titled “Exports”import { SlashCommand, createSlashCommandPlugin, slashCommandPluginKey, dismissSlashCommand, filterSlashItems, createSlashSuggestionRenderer,} from '@domternal/extension-block-controls';import type { SlashCommandOptions, SlashCommandProps, SlashCommandRenderer, SlashCommandPluginState, CreateSlashCommandPluginOptions,} from '@domternal/extension-block-controls';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-controls';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-controls';import type { SmartPasteOptions } from '@domternal/extension-block-controls';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-controls';
import type { BlockHandleOptions, BlockHandlePluginState, CreateBlockHandlePluginOptions, NestedConfig, BlockMatcher, BlockCandidate, MatchVerdict, BlockContextMenuOptions, BlockMenuItem, BlockMenuItemContext, BlockMenuItemGroup, CreateBlockContextMenuPluginOptions, TurnIntoTarget, WrapperCommand, SlashCommandOptions, SlashCommandProps, SlashCommandRenderer, SlashCommandPluginState, CreateSlashCommandPluginOptions, SmartPasteOptions, FloatingMenuOptions, CreateFloatingMenuPluginOptions, FloatingMenuKeymap,} from '@domternal/extension-block-controls';Source
Section titled “Source”@domternal/extension-block-controls - GitHub
See also
Section titled “See also”- Floating Menu - block-insert menu on empty paragraphs
- Block Color - block-level background and text colors
- Notion Mode - Notion-style block layout cookbook