Skip to content

0014. A block declares its caption and its toolbar and block menu items at client registration

Status: Accepted

Date: 2026-09-16

Context

A block's inline content can be text the block exists to hold, such as the callout's aside, or a caption to something else the block shows, such as the media block's figure. The slash menu is built for the first case: insertOrUpdateBlockForSlashMenu (node_modules/@blocknote/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts:72-88) inserts the chosen block after the current one when it has content, and calls updateBlock(currentBlock, block) when the content is empty. Inside an empty caption that second path converts the block itself and drops what it was captioning; inside a non-empty caption the first path inserts after the block, which still puts a block where a caption cannot hold one. js/src/editor.tsx's SuggestionMenuController already refuses to open in a table cell, upstream's own rule from node_modules/@blocknote/react/src/editor/BlockNoteDefaultUI.tsx:134-136, typed shouldOpen?: (tr: Transaction) => boolean at node_modules/@blocknote/core/src/extensions/SuggestionMenu/SuggestionMenu.ts:165 and called with view.state.tr at line 362. A caret inside a block's content sits in a ProseMirror node named after the block's own type (node_modules/@blocknote/core/src/schema/blocks/createSpec.ts:185-186), so the same mechanism can refuse a caption once the bundle knows which registered types are captions.

A block's own controls face the same gap on the other two editing surfaces. js/src/toolbar.tsx's policyFormattingToolbar() builds the formatting toolbar from getFormattingToolbarItems(), one static array of keyed elements (node_modules/@blocknote/react/src/components/FormattingToolbar/FormattingToolbar.tsx:28-55) each of which decides for itself whether it belongs, the way FileCaptionButton.tsx:16-50 reads the selection with useEditorState() and returns undefined unless exactly one block of its kind is selected; ToolbarSelect is exported the same way for a control that is a select rather than a button. Nothing in that list, or in js/src/blockmenu.tsx's blockMenuGroups(), comes from a block plugin outside this module. 0011 requires that every function reachable from a pointer-only surface also have a keyboard route through the Alt+F10 block menu, which the module's own colors, move and table operations already satisfy; an extension's block-level controls, such as a caption's alignment or a callout's tone, have had no way to add themselves to either surface.

BlockMeta already carries a fact that is not authorized by the PHP attribute alone: version, which buildClientSchema() (js/src/schema.tsx:287-293) reads off the registered meta to decide whether a specification is used at all. Registering these three facts beside it follows the same shape, and 0005 already draws the line between what the PHP plugin authorizes (storage, rendering, validation) and what the client specification is free to decide about editing. The PHP attribute has no consumer for a caption flag or a list of components: validation treats a caption as ordinary inline content either way, the media template already renders figcaption only when there is content, and both the toolbar items and the menu groups are React values that cannot cross the server boundary.

Decision

BlockMeta (js/src/runtime.ts) gains three optional members, read by the bundle and by nothing on the server:

  • inlineContent?: 'body' | 'caption'. Meaningful only for a specification with content: 'inline'. Omitted means 'body'. A block declared 'caption' is added to a set of caption block types the bundle computes once per editor, from the registry and the editor's own schema; slashMenuShouldOpen() in js/src/editor.tsx refuses to open the slash menu when the caret's parent node's name is a table-content node (upstream's own rule) or is in that set. This closes both the insert-after case and the empty-caption conversion case, because in both the slash character is left as ordinary text instead. Nothing else about editing a caption changes: styles, links and the formatting toolbar all work as they do in any other inline content, and a table cell is refused by upstream's own rule with no declaration at all. Amended 2026-09-20. The formatting toolbar for a text selection in a caption is now upstream's text tools alone, without Nest block and Unnest block; see 0025.
  • formattingToolbarItems?: readonly ComponentType<Record<string, never>>[]. Rendered by registeredToolbarItems() in js/src/toolbar.tsx, spliced into the toolbar's item list immediately before the element upstream keys boldStyleButton, which is where upstream's own file buttons, a block's own controls, already sit; the whole set of contributed items is appended at the end instead, not per block, when the toolbar carries no boldStyleButton at all. Limited to the block types the editor's schema holds, in meta.id order. A component is rendered exactly the way FileCaptionButton is: it reads the editor and the selection itself and renders null unless it applies, and it is drawn through the editor's own useComponentsContext(), so it carries the kit's markup, classes and data-test attributes. Each item is wrapped in a ContributedItemBoundary, a class component using getDerivedStateFromError, so a render that throws is caught, logged once with console.error naming the block, and rendered as nothing instead of unmounting the editor. Amended 2026-09-20. For a block declared 'caption', the bundle withholds these items only for a text selection inside the caption, mounting them as before for every other selection, including the node selection (0025); the item's own check stays, for which block among the caption blocks is selected.
  • blockMenuGroups?: (editor, block) => readonly BlockMenuGroup[]. Called by contributedGroups() in js/src/blockmenu.tsx on every Alt+F10 open, with the editor and the block holding the caret, and pushed onto the menu ahead of the bundle's own side-menu and table groups, each group's key prefixed ${block.type}: so it cannot collide with the bundle's own keys; an entry's own key is left as the factory returned it, scoped to its group rather than rewritten. A factory that throws is caught, logged once with console.error naming the block, and contributes nothing, the same isolation the bundle already gives a throwing slash-menu item factory (js/src/runtime.ts). Because a registered block may now contribute groups whether or not the side menu or the table handles are on, EditorView renders BlockMenuSurface unconditionally; Alt+F10 still does nothing when the menu it would open has no group to offer, which the existing groups.length === 0 guard in js/src/blockmenu.tsx already provides. Amended 2026-10-31. An open menu calls the factory again, with the block as it then is, whenever the document or the selection changes, and a fourth optional member, blockMenuSubscribe?: (onChange, editor) => () => void, lets a block tell an open menu that something its factory reads outside the document has changed: the menu calls it when it opens for a block of that type, calls the function it returns when it closes, and is built again on every call of onChange. The menu keeps focus on the item that had it by the item's group and entry keys, gives it to the item now at that position when the item goes, and closes, giving focus to the editor, once the caret has left the block it was opened for. registerBlock() refuses a blockMenuSubscribe that is not a function, a subscription that throws is caught and logged and the menu opens without it, and js/preset/blocknote-runtime.d.ts mirrors the member. Until then a menu kept what it offered when it opened, so the media block's own entries that wait on its preview stayed missing when the preview answered while the menu was open (0036). The runtime version stays '1': the member is optional, and a factory that reads only the document needs nothing new. Amended 2026-11-03. The factory no longer runs only on Alt+F10: the side menu's drag handle menu calls it too, through registeredMenuGroups() in js/src/blockmenu.tsx, with the block beside the drag handle, when the menu opens and again on a document change or a blockMenuSubscribe report, and places its groups ahead of BlockNote's own items there, so a pointer reaches a block's own functions on a new block with nothing selected; see 0011's amendment of the same date.

registerBlock() refuses, with console.error and false, a registration whose inlineContent is neither 'body' nor 'caption', whose formattingToolbarItems is not an array of functions, or whose blockMenuGroups is not a function, because a misspelled member is a defect in the extension and not a documented refusal.

Nothing is added to #[BlockNoteBlock] or to any per-format settings sent through editorSettings. The runtime version stays '1': the change is additive, every existing registration keeps working with the new members simply absent, and 0005's record of the contract growing by addition continues to hold.

BlockMenuEntry and BlockMenuGroup move from js/src/blockmenu.tsx into js/src/runtime.ts, unchanged, because the contract that types blockMenuGroups should own the shapes it returns; blockmenu.tsx re-exports both. The ambient js/preset/blocknote-runtime.d.ts mirrors all three members and the two moved interfaces as BlockNoteBlockMenuEntry and BlockNoteBlockMenuGroup. The block the factory receives is typed with the same broad, per-format schema types the module's own PolicyEditor and PolicyBlock already use for "whatever schema the format built" (js/src/colors.tsx), rather than with the bare BlockNoteEditor type's own defaulted generic parameters: the factory is always called with the actual per-format editor, and TypeScript's structural check of a concrete schema against the bare-defaulted type does not hold in general, the same reason PolicyEditor and PolicyBlock exist in the first place. The editor the factory receives is typed BlockNoteEditor<any, any, any>, which is what upstream itself uses for an item that acts on whatever block it is given (useBlockNoteEditor<any, any, any>() in its own block menu items). The broad schema types cannot be used for the editor: they collapse the property types of the PartialBlock that updateBlock() takes, so an extension calling editor.updateBlock(block, { props: ... }) inside its factory, which is the thing a menu factory exists to do, would not compile. The module's own menu code casts around this in three places; an extension should not have to.

Alternatives considered

A #[BlockNoteBlock] property echoed through editorSettings. Rejected. The registering script already knows the fact at the moment it calls registerBlock(); sending it through the PHP attribute and back through settings is a round trip for something that is a property of the block, not of the text format, and would need a per-format lookup for a per-block fact.

A slashMenu: false boolean. Rejected. It names a surface rather than the fact that is actually true of the block (its content is a caption), and the fact may later be what gates the heading # input rule or the Mod-Alt-N shortcuts inside a caption too, neither of which is in this record's scope; a boolean tied to one surface's name would need renaming or duplicating if that happens.

Taking over suggestionMenuComponent or filtering getItems per block. Rejected. That seam is upstream's whole slash menu, roughly seventy lines to own and keep current across upgrades, for a refusal that belongs at the trigger, not in the item list.

Naming drupalMedia directly in js/src/editor.tsx. Rejected. The editor bundle must not know a submodule's block type; the caption set is read from the registry precisely so the bundle stays ignorant of which extension, if any, declares a caption.

A per-editor factory for toolbar items, the way slash-menu items are factories. Rejected. A toolbar item is a component that reads the editor from React context with useBlockNoteEditor(), so a factory would be a second way to reach the same editor a component can already reach on its own; the slash menu's factory shape exists because a slash-menu item is not a component.

A callback handed to the block menu factory, which it calls to have the menu built again. Amended 2026-10-31. Not taken. The factory runs on every rebuild, so whatever it subscribed to would have to be ended before the next run and when the menu closes, which a function returning groups has no way to say. A separate member in the shape of the subscribe React's useSyncExternalStore() takes keeps the factory a function of what it reads, lets a store's own subscribe function be registered as it is, and leaves the subscription's lifetime to the menu.

Turning blockMenuGroups into an object holding the factory and the subscription. Amended 2026-10-31. Not taken. Nothing before 1.0.0 has to keep the old shape, but every block whose groups read only its document would change its registration for nothing, where an optional member leaves it as it is.

Rebuilding an open menu on every editor transaction alone, or on a timer. Amended 2026-10-31. Not taken. Transactions miss a change outside the document, which is what a media preview's answer is, and a timer rebuilds a menu nothing changed and still lags behind one that did.

Appending contributed toolbar items at the end of the list. Rejected. Upstream draws a block's own buttons, the file buttons, before the text style buttons; appending at the end would visually separate a block's controls from where upstream's own equivalents sit.

Consequences

The media block codes its formattingToolbarItems and blockMenuGroups against this contract and declares inlineContent: 'caption'; this record does not itself add any item to the media block. The callout declares none of the three members, because its text is the body of the aside and not a caption, and ExtensionTest::testTheSlashMenuOpensInsideTheCallout() pins that the slash menu still opens there.

EditorView renders the block menu surface on every editor now, not only when the side menu or the table handles are on; js/src/__tests__/editor.test.ts's surface expectation for every block-level surface switched off changed from [] to ['blockMenu'].

An extension's formatting toolbar item is React rendered inside the kit's own toolbar for the first time; it must take its markup from useComponentsContext() rather than drawing its own, the same discipline the module's own replacement buttons already follow. An item or a group that throws is isolated rather than taking the surface down with it, which is a defect this module now catches and logs on an extension's behalf rather than one that reaches the author as a blank editor.

The heading # input rule (node_modules/@blocknote/core/src/blocks/Heading/block.ts:175-176) and the Mod-Alt-N heading shortcuts carry no block-type guard in the source read for this record, so they may still convert a caption block that the slash menu now refuses to. Nothing in the current queue asks for closing that gap, and this record does not close it; the caption flag is available to a later record that does. Amended 2026-09-20. 0021 closes it, for every conversion rule and shortcut upstream ships, not the heading ones alone.

0011 is amended at the paragraph naming the block menu's own groups and at the paragraph on Tab reaching a block's controls, recording that a registered block's own groups and controls now exist alongside the bundle's.

References

  • node_modules/@blocknote/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts, SuggestionMenu.ts, node_modules/@blocknote/react/src/editor/BlockNoteDefaultUI.tsx, node_modules/@blocknote/core/src/schema/blocks/createSpec.ts.
  • node_modules/@blocknote/react/src/components/FormattingToolbar/FormattingToolbar.tsx, DefaultButtons/FileCaptionButton.tsx, DefaultSelects/BlockTypeSelect.tsx.
  • js/src/runtime.ts, js/src/editor.tsx, js/src/toolbar.tsx, js/src/blockmenu.tsx, js/src/colors.tsx, js/src/schema.tsx, js/preset/blocknote-runtime.d.ts, modules/blocknote_media/js/index.tsx.
  • Tests: js/src/__tests__/runtime.test.ts, editor.test.ts, toolbar.test.ts, blockmenu.test.tsx, modules/blocknote_media/js/__tests__/registration.test.ts, tests/src/FunctionalJavascript/MediaLibraryInsertionTest.php, ExtensionTest.php.
  • 0005, 0010, 0011.