Blockleaf

The chrome

The slash menu, bubble menu, drag handle and table controls — and how to replace any of them.

The default menus, toolbars and handles that @blockleaf/editor ships with.

Every piece of it is replaceable. The chrome lives in src/ui, imports only src/core and src/react, and nothing in the bindings imports back — so you can drop all of it and keep a working editor, or keep the plumbing and render your own surface.

The chrome never owns logic. Each button calls a command that already exists in src/core and already has tests. If a menu needs behaviour the command API cannot express, the command is what is missing — see "How a feature that spans blocks is split" in progress-tracker.md.

import { BlockleafEditor } from '@blockleaf/editor';
import '@blockleaf/editor/styles.css';

<BlockleafEditor />;

Styling is one stylesheet and --bl-* custom properties. No framework, no build step. Override a variable anywhere above the editor and the chrome follows.


Slash menu

Type / on an empty block. Filters as you type across titles, aliases, keywords and descriptions; ↑/↓ move, Enter runs, Escape dismisses.

PropDefaultWhat it does
slashMenutrueTurns the menu off entirely.
slashItems—An array of commands, or (defaults) => commands.
slashRegistry—A CommandRegistry instance, for full control.

The twelve defaults are Text, Heading 1–3, Bullet list, Numbered list, To-do list, Quote, Code, Callout, Divider and Table.

<BlockleafEditor
	slashItems={(defaults) => [
		...defaults.filter((command) => command.id !== 'callout'),
		{
			id: 'signature',
			title: 'Signature',
			group: 'Advanced',
			keywords: ['sign', 'sign-off'],
			run: (editor) => editor.chain().focus().insertContent('—').run()
		}
	]}
/>

CommandRegistry gives you add (with before, after or index), remove, reorder, filter, get, getAll and clone.

Commands that need an argument

A command can carry a picker — a component rendered in place of the list when the item is chosen, for when the command needs an argument before it can run. Table uses one: an 8 × 8 grid, driven by pointer or arrow key, with Enter to insert and Escape to go back to the list.

type SlashPickerProps = {
	editor: Editor;
	/** The `/query` text to replace when the command finally runs. */
	range: Range;
	/** Dismiss the picker and return to the list. */
	onCancel: () => void;
};

A picker replaces run, but run still has to exist. It is the path a host gets when it renders its own menu, and the path Mod+Alt+T already takes — the chrome may not be the only way to reach a command.


Bubble menu

Select text and a toolbar appears above it. It offers the six mark toggles and a link editor, reflects what is already applied through aria-pressed, and hides any toggle that cannot apply to the current selection.

It does not appear inside a code block, where none of these marks can apply — a toolbar offering them there would be lying.

PropDefaultWhat it does
bubbleMenutrueTurns the toolbar off entirely.
bubbleActions—An array of actions, or (defaults) => actions.
linkEditortrueWhether the toolbar offers the link editor.
bubbleShouldShow—Overrides when the toolbar appears.

Keyboard

KeyDoes
← / →Move between buttons, wrapping at both ends.
Home / EndFirst / last button.
EscapeReturn focus to the document.
Mod+KOpen the link editor for the selection.

The toolbar is a single tab stop — a roving tabindex, as the ARIA toolbar pattern specifies — so Tab moves past it rather than through every button.

Actions

A BubbleAction is the smallest thing the toolbar can render:

type BubbleAction = {
	id: string;
	title: string;
	shortcut?: string;
	icon?: ReactNode | ComponentType<IconProps> | (() => ReactNode);
	isActive: (editor: Editor) => boolean;
	isAvailable?: (editor: Editor) => boolean;
	run: (editor: Editor) => void;
};

isActive drives aria-pressed. isAvailable returning false removes the button for that selection rather than disabling it — a disabled control that is never enabled is noise for a screen reader.

<BlockleafEditor
	bubbleActions={(defaults) => defaults.filter((a) => a.id !== 'highlight')}
/>

Mod+K, or the link button, opens a single field. Enter applies, Escape closes, an empty field removes the link. When the cursor is already inside a link the field is pre-filled and buttons to open and remove it appear.

The field is a convenience, not a gate. It calls the same sanitizeHref the schema calls, only so it can say why an href will not stick. A javascript: URL typed here is rejected twice: once with a message, and once by the schema, which would have rejected it regardless of what the UI did. See schema.md.

Allowed: https:, mailto:, site-relative paths and hash anchors. Everything else is refused.

Replacing it

The parts are exported, so you can keep the positioning and ship your own surface:

import {
	BubbleMenu,
	LinkEditor,
	createBubbleMenuExtension,
	defaultBubbleActions
} from '@blockleaf/editor';

createBubbleMenuExtension({ actions, link, shouldShow, pluginKey, appendTo }) returns the Tiptap extension on its own, for when you are assembling extensions by hand rather than using <BlockleafEditor />.


Drag handle and block menu

Hover a block and a handle appears in the gutter beside it. Drag it to move the block; click it for the block menu. A + button inserts an empty paragraph below.

PropDefaultWhat it does
dragHandletrueTurns the handle and its menu off entirely.
blockActions—An array of actions, or (defaults) => actions.
insertButtontrueWhether the + button is shown.

The handle starts the drag; it does not implement one. It sets a NodeSelection on the block and hands ProseMirror view.dragging, so dropping goes through the same code path as any other ProseMirror drag — including inside tables, and including undo.

The block menu

Two groups. Turn into converts the block — Text, Heading 1–3, Bullet list, Numbered list, To-do list, Quote, Code — and marks the current type with aria-current. Actions holds Move up, Move down, Duplicate and Delete.

Move up on the first block, and move down on the last, are removed rather than disabled, on the same reasoning as the bubble menu: a control that is never enabled is noise for a screen reader.

KeyDoes
↑ / ↓Move through the entries.
Home / EndFirst / last entry.
EnterRun the focused entry.
EscapeClose, running nothing.

Actions

type BlockAction = {
	id: string;
	title: string;
	group: 'Turn into' | 'Actions' | (string & {});
	icon?: ReactNode | ComponentType<IconProps> | (() => ReactNode);
	isActive?: (editor: Editor, pos: number) => boolean;
	isAvailable?: (editor: Editor, pos: number) => boolean;
	danger?: boolean;
	run: (editor: Editor, pos: number) => void;
};

pos is the position before the top-level block the handle points at.

<BlockleafEditor
	blockActions={(defaults) => [
		...defaults,
		{
			id: 'copy-id',
			title: 'Copy block id',
			group: 'Actions',
			run: (editor, pos) => {
				const node = editor.state.doc.nodeAt(pos);
				if (node) navigator.clipboard.writeText(node.attrs.id);
			}
		}
	]}
/>

Why it is hand-rolled. @tiptap/extension-drag-handle imports @tiptap/y-tiptap and @tiptap/extension-collaboration at module scope, so a document that will never be collaborative would still ship yjs — 32.87 kB, which is over the whole bundle ceiling. See F12 in open-flags.md. The trade is that this handle moves one block at a time; multi-block range dragging is not implemented.


In-table controls

Put the cursor in a table and two handles appear: one above the current column, one to the left of the current row. Each opens a menu of operations for that axis.

PropDefaultWhat it does
tableControlstrueTurns the handles off entirely.
tableActions—An array of actions, or (defaults) => actions.

Row menu: insert above, insert below, toggle header row, merge cells, split cell, delete row, delete table. Column menu: insert left, insert right, toggle header column, merge cells, split cell, delete column, delete table.

Every entry calls a command from EC-14 or EC-15, and asks that same command whether it can run — editor.can().mergeCells() rather than re-deriving the answer. Two implementations of "can you merge this" would eventually disagree, and the command is the one that is already tested. An entry whose command cannot run is removed from the menu rather than disabled.

Only the current row and column get a handle, not a strip of one per row and column. A strip means measuring every cell on every selection change and re-measuring on every resize, and it sits on the table's edges in a way that fights text selection. The cursor already says which row and column the user means.

type TableAction = {
	id: string;
	title: string;
	axis: 'row' | 'column' | 'both';
	icon?: ReactNode | ComponentType<IconProps> | (() => ReactNode);
	isAvailable?: (editor: Editor) => boolean;
	danger?: boolean;
	run: (editor: Editor) => void;
};

The cell menu

Select across cells and a second bubble menu appears, offering merge, split, toggle header cell, insert row or column, delete row, and delete table.

PropDefaultWhat it does
cellMenutrueTurns the cell toolbar off entirely.
cellActions—An array of actions, or (defaults) => actions.

It is the same surface as the text bubble menu — a second instance with its own plugin key, its own actions and its own shouldShow — which is why CH-02's bubble menu was built around the framework-agnostic plugin rather than a fixed React component.

The two never appear together. A cell selection belongs to the cell menu, and the text menu stands down for it; a caret inside a single cell is ordinary text editing, which the row and column handles already cover.


Image menu

Select an image and a toolbar appears above it: align left, centre, right, alt text, delete.

PropDefaultWhat it does
imageMenutrueTurns the image toolbar off.
imageActions—An array of actions, or (defaults) => actions.

A keyboard reaches it the same way it reaches any atom block: arrowing onto an image selects the node, which is what the menu keys on. No extra shortcut is needed, and none is invented.

This is a third instance of the bubble menu, after the text toolbar and the cell menu — same surface, own plugin key, own shouldShow. The text toolbar stands down automatically, because its shouldShow requires text in the range and an image has none.

Alt text

The alt field writes directly to the node's alt attribute.

An empty field is an answer, not a blank. Submitting it sets alt to the empty string, which is how HTML marks an image decorative so a screen reader skips it. That is different from an image with no alt at all, which a screen reader announces by reading the filename. The hint under the field says so, and the button says "Add alt text" or "Edit alt text" depending on which of the two states the image is in.

Alignment

left, center, right, or unset. Unset renders flush left, so the align left button reports itself as pressed while the attribute is null — the toolbar agreeing with the page matters more than the toolbar agreeing with the attribute.

Alignment is an attribute on the image node rather than a wrapper element, so the stored document stays one node and the HTML round-trips.


Placeholder

The empty block the cursor is in gets a label. Only that one — labelling every empty block at once turns a new document into a wall of grey text, which reads as content rather than as absence.

PropDefaultWhat it does
placeholder"Type '/' for commands"A string for every block, per-node labels, or false.
<BlockleafEditor placeholder="Start writing…" />

<BlockleafEditor
	placeholder={{
		paragraph: 'Write something',
		heading: (level) => `Heading ${level}`,
		fallback: ''
	}}
/>

It renders as a data-placeholder attribute on a decorated node, styled by .blockleaf-empty-node::before, and disappears when the editor is read-only or when text is selected.

Trailing node

A document always ends somewhere you can click. Without this, a document ending in a table, a code block or a divider is a document you cannot type at the end of.

This is core behaviour, not chrome — it changes the stored document, so it lives in createExtensions and a headless consumer gets it too. No trailing paragraph is added after a paragraph or a heading, because that paragraph would be saved and the stored document is the product.

Opening a saved document that needs one is not an edit: the insert carries a normalisation flag, stays out of the undo history, and does not reach onChange. A document you only opened has not changed.


Keyboard reference

Everything the chrome does is reachable without a pointer. This is the CH block's exit condition, not a nice-to-have, and e2e/accessibility.spec.ts builds a document — heading, list and table — using only the keys below.

KeyDoes
/Open the slash menu.
↑ ↓Move through a menu.
← →Move along the bubble menu, or around the grid picker.
Home EndFirst / last entry.
EnterRun the focused entry.
EscapeDismiss. Returns focus to the document.
BackspaceIn the grid picker, back to the list.
Mod+KLink editor for the selection.
Shift+F10Block menu for the current block.
Mod+Alt+MBlock menu, for keyboards without F10.
Mod+Alt+R / CRow / column menu, inside a table.
Mod+Alt+TInsert a 3 × 3 table, no menu involved.
Tab / Shift+TabNext / previous cell, inside a table.

Shift+F10 is the platform's context-menu key, and the one a screen reader user reaches for first. The drag handle only appears on hover, so without it the block menu would be mouse-only.

The table menus need their own keys because Tab inside a table is cell navigation — there is no tab-stop route to the handles.

Accessibility

Every surface is checked by axe, twice: in jsdom over each component (tests/accessibility.test.tsx), and in a real browser over the rendered page (e2e/accessibility.spec.ts), where colour contrast and focus visibility can actually be measured. The unit suite includes a test that feeds axe deliberately broken markup — a gate that cannot fail is not a gate.

Conventions the chrome holds to:

  • A control that can never be enabled is removed, not disabled. A permanently disabled button is noise for a screen reader.
  • Each toolbar is one tab stop, with a roving tabindex, as the ARIA toolbar pattern specifies.
  • Escape always returns focus to the document, never to a handle that is about to disappear.
  • The editing surface carries an accessible name — label, defaulting to "Document". A contenteditable is an ARIA textbox, and an unnamed one is announced as nothing at all.
<BlockleafEditor label="Release notes" />

Known and deferred to HD-01: the slash menu is a listbox driven by aria-activedescendant on itself rather than on the editor that holds focus. The full combobox pattern wants the relationship expressed on the textbox.


Actions follow the extension set

Every default action declares isAvailable, and the first thing it checks is whether the command exists at all. With preset="minimal" there is no highlight mark, so the bubble menu simply has five toggles instead of six; with an extensions array of your own that omits headings, the block menu's "Turn into → Heading 1" is not offered.

An action of yours should do the same — the chrome is rendered against whatever schema it is handed, not against the standard preset:

{
	id: 'mention',
	title: 'Mention',
	group: 'Insert',
	isActive: (editor) => editor.isActive('mention'),
	isAvailable: (editor) => 'mention' in editor.schema.nodes,
	run: (editor) => editor.chain().focus().insertMention().run()
}

Without the guard, editor.can().insertMention() throws rather than returning false: editor.can() only carries the commands the loaded extensions registered. See extensions.


Styling

Both menus read the editor's --bl-* variables and define their own scoped fallbacks, so they follow a theme without being coupled to one. Dark mode is automatic via prefers-color-scheme, and [data-theme='dark'] or [data-theme='light'] on the menu forces it.

Variable groupExamples
Slash menu--bl-slash-bg, --bl-slash-selected, --bl-slash-shadow
Bubble menu--bl-bubble-bg, --bl-bubble-active, --bl-bubble-danger

Class names are stable and BEM-flavoured — .blockleaf-bubble-menu, .blockleaf-bubble-menu__button, .blockleaf-slash-menu__item, .blockleaf-block-handle__button, .blockleaf-block-menu__item — so a stylesheet of your own can target them without !important.

On this page