Skip to content

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

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-overlays event)

Skip it if:

  • Your editor is a simple bubble-menu + toolbar setup
  • Your users expect Word-style editing (no drag handles, no slash command)
Terminal window
pnpm add @domternal/extension-block-controls

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.


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 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, 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:

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 | 'left' | 'right' | 'both' | 'none' | objectfalsePenalise deep matches near edges so shallower ancestors win
matchersBlockMatcher[][]Append custom block matchers
defaultMatchersbooleantrueKeep 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.

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

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

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-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-editor only when dropIndicator: 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.

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

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

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

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.

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

FieldTypeNotes
idstringBecomes the data-block-menu-item attribute, so it doubles as a test handle
labelstringRendered text
iconstringKey into the shared icon set. Register your SVG into defaultIcons first; an unknown key renders an empty icon span rather than throwing
groupBlockMenuItemGroupOne of primary, colors, turnInto, collaboration
ordernumberWithin the group, ascending. Defaults to 100; step by 10 to leave room
isAvailable(ctx) => booleanHides 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) => voidCalled 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.

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

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--disabled - a contributed item that returned { disabled: true, reason } from isEnabled. The button also carries aria-disabled="true" and the reason as its title. 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
import {
BlockContextMenu,
createBlockContextMenuPlugin,
blockContextMenuPluginKey,
} from '@domternal/extension-block-controls';
import type {
BlockContextMenuOptions,
BlockMenuItem,
BlockMenuItemContext,
BlockMenuItemGroup,
CreateBlockContextMenuPluginOptions,
TurnIntoTarget,
WrapperCommand,
} from '@domternal/extension-block-controls';

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-controls';
dismissSlashCommand(editor.view);
import {
SlashCommand,
createSlashCommandPlugin,
slashCommandPluginKey,
dismissSlashCommand,
filterSlashItems,
createSlashSuggestionRenderer,
} from '@domternal/extension-block-controls';
import type {
SlashCommandOptions,
SlashCommandProps,
SlashCommandRenderer,
SlashCommandPluginState,
CreateSlashCommandPluginOptions,
} from '@domternal/extension-block-controls';

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

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-controls';
import type { SmartPasteOptions } from '@domternal/extension-block-controls';

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

@domternal/extension-block-controls - GitHub