0025. The formatting toolbar is keyed on the selection kind for a caption block¶
Status: Accepted
Date: 2026-09-20
Context¶
0019 put the media block's functions on the one formatting toolbar "for a selection that is exactly one media block", and useSelectedMediaBlock() (modules/blocknote_media/js/toolbar.tsx:116-142) answers for a node selection and for a text selection inside the caption alike, because neither the selected block's identity nor the shape getSelection()?.blocks ?? [getTextCursorPosition().block] returns tells the two apart.
Upstream's own text items hide only for a block whose content is undefined (BasicTextStyleButton.tsx:79, ColorStyleButton.tsx:67, CreateLinkButton.tsx:75, NestBlockButtons.tsx:37,94), and a caption block has inline content, so both sets, the block's own and upstream's text tools, met on both selections. Upstream has no block with both chrome of its own and a rich inline caption to have discovered this against: its own file blocks are atoms whose caption is a string prop edited from FileCaptionButton, never a selection of text.
The concrete harm was measured against the module: a text item used from the node selection acts on the block's bounds rather than on any typed text, so the link button linked the entire caption without a character of it selected (StyleManager.ts:198-208, tr.addMark(from, to, linkMark) over the node selection's own range). Upstream's own getSelection() returns undefined for a node selection exactly as it does for a collapsed caret (node_modules/@blocknote/core/src/api/blockManipulation/selections/selection.ts:26-28, "node" in tr.selection), so nothing built on that idiom alone can tell a node selection on a block from a caret inside it; editor.prosemirrorState.selection instanceof NodeSelection can, and both js/src/portable/blockselect.ts:101 and js/src/portable/caption-keys.ts:154 already produce that kind of selection by node-selecting the block's own content node, whose name is the block's registered type. The formatting toolbar's own positioning needs nothing extra either way: its controller already anchors a node selection to the block and a text selection to the selected text (node_modules/@blocknote/react/src/components/Popovers/PositionPopover.tsx:32-33, posToDOMRect()).
Decision¶
One toolbar, keyed on the selection kind, in js/src/toolbar.tsx. toolbarSelectionKind() classifies the current selection as captionBlock for a NodeSelection whose one selected block is registered inlineContent: 'caption' (0014); as captionText for any other selection that still resolves to exactly one such block; and as other for everything else, no selected block, more than one, or a block that is not a caption.
formattingToolbarItems() takes this kind as a fourth argument, defaulted to other so every existing caller keeps its current behavior. For captionBlock it renders the contributed items alone, none of upstream's own. For captionText it renders upstream's own items alone, with nestBlockButton and unnestBlockButton removed, because both act on the block rather than on its text, and a caption's own block operations, Indent and Outdent, are reached through the Alt+F10 menu instead (0011); nothing is spliced in for the contributed items. For other the function is unchanged from before this record. policyFormattingToolbar() reads the kind with a useEditorState() selector over toolbarSelectionKind(), so the toolbar re-renders only when the classification itself changes, useEditorState()'s own comparison by fast-deep-equal.
BlockMeta (0014) gains no member; a block declared inlineContent: 'caption' gains a fourth consequence of that declaration, alongside the slash menu, Add block and the click rule, and a contributed item keeps deciding for itself whether its own block is the one selected, exactly as useSelectedMediaBlock() already does. The block menu is unchanged: Alt+F10 already offers the block's functions from the caret, without regard to the selection kind, and this record does not touch it.
The gate lives in the extension bundle, js/src/toolbar.tsx, and not inside useSelectedMediaBlock() in the media bundle. The rule is about every block registered inlineContent: 'caption', a set only the extension bundle computes (captionsAmong(), js/src/runtime.ts:343-353), and the media bundle cannot import prosemirror-state at all: js/preset/vite.preset.ts:69 refuses prosemirror-* at every entry point, and js/preset/README.md:83 explains why. modules/blocknote_media/js/toolbar.tsx's own docblock gains one sentence, at the file level and again at useSelectedMediaBlock(): the bundle withholds this item only for a text selection inside the caption, and for every other selection, including the node selection, mounts it as before; the check here is only which block among the caption blocks the toolbar renders is selected, not the selection's kind.
Alternatives considered¶
A second floating toolbar for the caption. Rejected. Both would be open at once for a text selection that also happens to satisfy the node toolbar's own condition, giving two Tab routes into two toolbars for one block, and upstream offers no primitive for a second selection-driven toolbar to coexist with its own.
Gating inside useSelectedMediaBlock() with a duck-typed 'node' in selection check. Rejected. That would compile in the media bundle, which cannot import prosemirror-state's own NodeSelection, but it would be a second gate that can drift from the one in js/src/toolbar.tsx, and it would answer for one block's own hook rather than for the rule this record states about every block registered inlineContent: 'caption'. No browser test could tell the two gates apart until they disagreed.
Keeping Nest block and Unnest block in a caption's text selection. Rejected. Both operate on the block from a text selection, the same mismatch between what is selected and what an item acts on that this record removes for the block's own items.
Consequences¶
The formatting toolbar is the pointer route to a caption block's own functions only through the node selection; the caption's own text tools are reached by selecting text inside it instead, and both keep Tab as their route into the toolbar, whatever the selection kind, because upstream's own Tab handler passes into the toolbar once it is shown, without regard to what is selected (node_modules/@blocknote/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts:960-973). Amended 2026-09-20. A caption block that contributes no toolbar item shows no toolbar at all for a node selection; the toolbar renders nothing rather than an empty box.
tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php::testMediaBlockControlsHaveNamesAndKeyboardAccess() pins the node selection route as carrying exactly the seven media data-test values and the text selection route as carrying upstream's own text tools without those seven values and without nestBlock or unnestBlock. js/src/__tests__/toolbar.test.ts pins formattingToolbarItems()'s three branches, and the new js/src/__tests__/toolbar-selection-kind.test.ts pins toolbarSelectionKind()'s own classification against a real mounted editor. Amended 2026-10-31. The browser test keeps the node selection route only. toolbar-selection-kind.test.ts draws the toolbar with the real kit for both routes, a node selection on a caption block holding only the block's own items and a text selection in its caption holding upstream's text tools without them and without nestBlock or unnestBlock, against a paragraph's text selection that keeps them.
0019's toolbar paragraph and its 2026-09-19 Consequences amendment are superseded in the part that said a text selection in the caption reaches the same five functions as a node selection; both are amended in place with the correction. 0014's inlineContent and formattingToolbarItems paragraphs are amended to say what declaring a block's inline content a caption now means for when its toolbar items are mounted. docs/extending.md and README.md state the same rule for a module author and for a site builder.
Dropping the nest and unnest buttons from a caption's text selection removes the one pointer route that selection offered to indenting the captioned block, and the node selection offers no upstream items at all to replace it with; Alt+F10's own Indent and Outdent remain the route (0011), unaffected by this record.
References¶
node_modules/@blocknote/core/src/api/blockManipulation/selections/selection.ts,src/editor/managers/StyleManager.ts,src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts.node_modules/@blocknote/react/src/components/FormattingToolbar/FormattingToolbar.tsx,DefaultButtons/BasicTextStyleButton.tsx,DefaultButtons/ColorStyleButton.tsx,DefaultButtons/CreateLinkButton.tsx,DefaultButtons/NestBlockButtons.tsx,src/components/Popovers/PositionPopover.tsx.js/src/toolbar.tsx,js/src/runtime.ts,js/src/portable/blockselect.ts,js/src/portable/caption-keys.ts,js/preset/vite.preset.ts,js/preset/README.md,modules/blocknote_media/js/toolbar.tsx.- Tests:
js/src/__tests__/toolbar.test.ts,toolbar-selection-kind.test.ts;tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php. - 0011, 0014, 0019, 0021.