0021. A caption refuses every block conversion rule and shortcut¶
Status: Accepted
Date: 2026-09-20
Context¶
Every upstream input rule and keyboard shortcut that converts a block's type checks one thing before it acts: whether the block holding the cursor is content: 'inline'. The central check sits where every input rule's replace is called, getProsemirrorPluginsFromExtension() (node_modules/@blocknote/core/src/editor/managers/ExtensionManager/index.ts:556-574): replace() is called at line 556, and only when it returns a truthy replaceWith does the block get converted, gated at lines 565-568 on this.editor.schema.blockSchema[blockInfo.blockNoteType]?.content !== "inline". Every block's input rules are collected into one ProseMirror plugin (index.ts:458-478) with a sidecar that replays the same rules on Enter (index.ts:481-514), because the underlying regex, ending \s$, already matches a newline. Each conversion shortcut repeats the identical check inline rather than sharing it: createHeadingKeyboardShortcut() (node_modules/@blocknote/core/src/blocks/Heading/block.ts:21-38) reads editor.schema.blockSchema[cursorPosition.block.type].content !== "inline" before calling editor.updateBlock(), and the same three lines appear, unchanged apart from the target type, in Quote's Mod-Alt-q (Quote/block.ts:60-75), Bullet's Mod-Shift-8 (ListItem/BulletListItem/block.ts:90-105), Numbered's Mod-Shift-7 (ListItem/NumberedListItem/block.ts:121-136), Check's Mod-Shift-9 (ListItem/CheckListItem/block.ts:139-154), Toggle's Mod-Shift-6 (ListItem/ToggleListItem/block.ts:109-124), and Paragraph's own Mod-Alt-0 (Paragraph/block.ts:61-76), which converts the cursor's block back to a paragraph, found reading the source to build this record's test matrix, and carries the same defect.
A block registered inlineContent: 'caption' (0014) is declared with content: 'inline' too, because its caption is ordinary inline text the format's policy governs the same as any paragraph's. It therefore passes every one of these checks. Typing # at the start of a media caption, or pressing Mod-Alt-1 with the caret there, calls editor.updateBlock(cursorPosition.block, { type: 'heading', props: { level: 1 } }), which replaces the block's type and props wholesale; the media block's mediaUuid and viewMode are gone, and the block that remains is a heading holding whatever text the caption held. The same is true of every other route listed above, and of the divider's input rule (Divider/block.ts:39-47), which replaces the block with { type: 'divider', props: {}, content: [] }, discarding the caption's text along with the reference. The code block's fence rule (Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts:101-119, the extension built at Code/block.ts:11,50-56 under the key code-block-keyboard-shortcuts) does the same for ```.
The block type select cannot reach a caption block either way, for a different reason: BlockTypeSelect.tsx (node_modules/@blocknote/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx:146-206) builds its options from a fixed list of the default block types (blockTypeSelectItems()) and shows itself only when one option's type and props match the current selection (shouldShow, line 202-205); a media block's type is never in that fixed list, so no option is ever marked selected and the control renders nothing. It needs no guard because it never offers the conversion in the first place. The slash menu and the side menu's Add block button are already gated by 0014's inlineContent: 'caption' declaration; this record closes the remaining gap, the one 0014's own Consequences named and left open: "The heading # input rule … 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." Amended 2026-09-20. That holds only for a selection that is a single caret, or wholly inside one block: shouldShow checks only the first selected block, and onClick (BlockTypeSelect.tsx:178-186) converts every selected block, so a selection that begins in an ordinary block and runs into a caption block, Shift+Down from the block before it, or Ctrl+A, shows the select on the strength of the first block and converts the caption along with the rest, dropping its props the same way the routes below do. js/src/toolbar.tsx's PolicyBlockTypeSelect reimplements the control, keeping shouldShow exactly as upstream computes it and skipping a caption block in onClick.
Decision¶
js/src/caption-guard.ts's guardCaptionConversions(specs, captions) wraps every non-caption block specification's extensions so their input rules and shortcuts refuse to act while the cursor is on a caption block, called once in js/src/schema.tsx's buildClientSchema() before BlockNoteSchema.create(). The caption set is computed from the specifications being assembled, the same way js/src/runtime.ts's captionsAmong() computes it for js/src/editor.tsx's captionBlockTypes(); that helper was moved from editor.tsx into runtime.ts so both call sites share it rather than each reading the registry themselves.
For every specification whose own type is not itself a caption block, each item in its extensions array (a factory, (ctx) => Extension, or an already-built Extension) is replaced by a version that returns a shallow copy, spreading the original: its inputRules[].replace is wrapped to return undefined when editor.getTextCursorPosition().block.type is in the caption set, and its keyboardShortcuts[key] handlers are wrapped to return false under the same condition. editor.getTextCursorPosition() falls back to a node selection's own block when there is one, which is what also refuses a shortcut pressed with the caption block selected by a click rather than the caret inside it (js/src/portable/blockselect.ts).
A refused input rule leaves the typed character as text. replace() returning a falsy value means replaceWith is falsy in the central check at index.ts:561, so nothing is dispatched for that rule; @handlewithcare/prosemirror-inputrules's own run() (vendored under node_modules/@handlewithcare/prosemirror-inputrules/dist/inputrules.js) then tries the next matching rule, and when none of them produces a transaction, run() returns false from handleTextInput, which is what leaves ProseMirror to carry out its own default text insertion, exactly the branch a code block or a table cell already takes for the same trigger today. A refused shortcut returns false, the identical "not handled" result upstream's own content !== 'inline' check already returns for a table cell or a code block, so nothing about how an author already experiences a declined shortcut changes.
The spread preserves what the priority sort and the factory map read. createExtension()'s object-form factory stamps a symbol onto the extension object it returns (BlockNoteExtension.ts, originalFactorySymbol), and a symbol assigned with obj[sym] = value is an own, enumerable property, so { ...extension, inputRules: …, keyboardShortcuts: … } copies it exactly as it copies key and runsBefore. ExtensionManager's priority sort, sortByDependencies() (util/topo-sort.ts), reads only .key and .runsBefore off the built extension instances, so a copied extension sorts identically to the original; addExtension()'s own factory-identity map (extensionFactories) still resolves correctly because it keys off the symbol the spread carried over, not off the wrapping function's own identity. js/src/__tests__/caption-guard.test.ts pins both: a hand-built extension's own keys all survive a guarded copy, and editor.getExtension('heading-shortcuts') on a real, guarded schema still returns a defined extension with all six input rules and a Mod-Alt-1 handler. No mutate-in-place fallback was needed.
A replace() that returns undefined leaves the character. Confirmed by driving the real handleTextInput plugin prop against a real, mounted editor: typing # and - into an empty caption leaves the block's type unchanged and the two characters in its content, where nothing else in the test file could distinguish "the plugin declined" from "the plugin silently ate the keystroke" except an actual round trip through the input rules plugin's own run(). No handleTextInput pre-empt was needed.
Amended 2026-09-20. The default keymap's own Backspace, Delete and Enter join or split a caption the same way, and are guarded too. Two reviewers verified by probe that upstream's default KeyboardShortcutsExtension (node_modules/@blocknote/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts) binds Backspace, Delete and Enter directly, outside any specification's own extensions, checking only the node name a block holds: Backspace at the start of a block reverts its content type to a paragraph, or merges it into the block before, or drops the block before if that one holds no content; Delete at the end of a block merges the block after into it; Enter at the start of a non-empty block splits it, keeping the original type and props on both halves. None of this is an input rule or a shortcut a specification declares, so guardCaptionConversions() never sees it. js/src/portable/caption-keys.ts binds the same three keys ahead of it (a module extension's keymap gets priority 101 against a default tiptap extension's 100, util/topo-sort.ts:166-190) and, when the block a join or a split would touch is a caption block: node-selects it on Backspace at its own start, or on Backspace or Delete at the boundary of the block beside it, instead of converting, merging or dropping it, upstream's own pattern for a non-text node; a second press then deletes the node-selected block, through upstream's own commands.deleteSelection(), first in both chains, because a node selection is never empty, and one undo restores it. Enter at the start of a non-empty caption splits it the way Enter in the middle of a caption already does, splitBlockCommand's own semantics with neither the type nor the props kept, so the caption, now empty, keeps its own type and props and the text after the cursor becomes a new paragraph after it. Enter in an empty caption is left alone: upstream's own empty-block branch inserts a new block after the current one without touching it, which is not destructive.
Amended 2026-10-31. On BlockNote 0.57.0 the guards hold, through upstream's new block info API. 0.56.0 removed getBlockInfo() and getBlockInfoFromResolvedPos() and exports getPrevBlockInfo() and getNextBlockInfo(), so js/src/portable/caption-keys.ts uses upstream's own instead of the twins it carried. 0.57.0 adds Mod-Alt-c, which turns the block holding the caret into a code block, to the code block's own code-block-keyboard-shortcuts extension; it is a specification's own shortcut, so guardCaptionConversions() wraps it with no change, and js/src/__tests__/caption-guard.test.ts names it among the shortcuts it checks. The experimental_keyboard option 0.56.0 adds to a block implementation does not replace the Backspace and Delete guard: a caption block declaring its own type as resetsTo would keep Backspace from resetting it, but upstream would still merge it into the block before, and drop or merge it from the block after, and the option is marked as one that may change. The block type select still converts every selected block in upstream's own BlockTypeSelect, so PolicyBlockTypeSelect stays; its items are now upstream's blockTypeSelectItems(editor), which follow the editor's schema since 0.56.0, in place of a list the module narrowed by the format's heading settings itself.
Amended 2026-10-31. Enter is upstream's own again. Since BlockNote 0.56.0 the default keymap answers Enter at the start of non-empty content by inserting an empty block above it and leaving the block itself, its id, type and props included, where it was; it no longer splits the block keeping its type and props on both halves, the duplication the amendment above guarded a caption against. Enter at the start of a caption that holds text therefore inserts an empty paragraph before the caption block and leaves the caption whole, rather than moving the caption's text into a new paragraph after it, and js/src/portable/caption-keys.ts no longer binds Enter. It binds Backspace and Delete only, which upstream still resets, merges and drops as the amendment above describes. js/src/__tests__/caption-keys.test.ts and CaptionEditingTest::testEnterAtCaptionStartLeavesTheBlockWhole() hold upstream to the new Enter. Amended 2026-10-31. The browser method is gone, with the browser methods for #, -, the node-selected block, Backspace and Delete, because vitest proves what a key does to a document against a real editor: caption-keys.test.ts holds upstream to the new Enter alone, and CaptionEditingTest keeps one real Ctrl+Alt+1 chord in a caption.
Amended 2026-10-31. Enter with text selected from a caption's start, and Backspace and Delete across nesting, are guarded too. The amendment above holds for a caret, not for a selection. With text selected and its anchor at the start of a caption, upstream's Enter deletes the selection and then splits the block at the anchor, and a split there keeps the block's type and every prop on both halves, so two blocks carried one media reference. That was so on 0.54.2 as well, since the Enter guard the amendment above retired returned early on any selection, and 0.56.0 added a second route to it: Ctrl+A or Cmd+A in a caption selects the caption's text from its start. js/src/portable/caption-keys.ts binds Enter again for that case alone: it deletes the selection in a transaction of its own and lets upstream's Enter run on the caret left at the caption's start, so the result is what the amendment above describes for a caret, and undo takes the two back in two steps. A selection whose anchor is elsewhere, the caption's start at its head included, is split as upstream splits any block, without carrying the caption's type or props. Backspace and Delete were guarded against the block immediately before and after only, while upstream's joins reach across nesting: Delete at the end of a block merges in its first child, or, with none, its next sibling, or the next sibling of the nearest ancestor that has one, and Backspace at the start of a block merges it into the last descendant of its previous sibling, or into its parent when it is a first child. The guards now look where upstream's joins look, and node-select a caption found there. Both gaps were already on 0.54.2.
Amended 2026-10-31. Backspace leaves upstream's reset alone. Upstream's Backspace chain resets a block whose content is not already the type and props its experimental_keyboard names in resetsTo, a heading or a list item turned back into a paragraph, before it tries any join, and a reset joins nothing. The guard looked for a caption at the join first, so Backspace at the start of a heading after a caption node-selected the caption, and a second press deleted it, where the author had asked for the heading to become a paragraph; for a heading that is a caption block's first child, the second press emptied the caption and dropped its reference. js/src/portable/caption-keys.ts now reads resetsTo the way upstream does, from the block's own spec, and leaves Backspace to upstream whenever the reset would run, so the guard answers the next press, on the block in its reset form. A caption block itself is still node-selected at its own start before any reset, since resetting it is what the guard exists to prevent.
Amended 2026-10-31. Enter's deletion keeps the caption a forward selection starts in. The Enter amendment above holds for a selection inside the caption. A selection running from the caption's start on into the blocks after it was deleted with deleteSelection(), whose replaceRange() widens a range that starts at the start of a block's content to the whole block, so the caption block went with it, reference included, before upstream's Enter ran. The guard now deletes a selection whose head is after its anchor with tr.delete(), which keeps the block the range starts in, joins into it what is left of the block the range ends in, and leaves the caret at the caption's start, so upstream's Enter for a caret runs on a caption that still carries its reference. A selection whose head is before its anchor keeps deleteSelection(): run back over the whole block before the caption, it drops that block and keeps the caption, where tr.delete() would join the caption into it. Run back to partway through that block, either deletion joins the caption into it and the reference is lost. That gap stays open, as does Backspace or Delete with a selection from a caption's start to past its end, or back to partway through the block before it, which upstream's own chains delete with deleteSelection() and this guard does not see; measured in vitest, the caption block is dropped in each case. The deletion and upstream's Enter are two transactions, and prosemirror-history records them as one undo step when their changes touch, as they do after a selection run back over a whole block, so the two undo steps the amendment above names hold for a selection inside the caption, not for every selection.
Alternatives considered¶
A filterTransaction veto that rejects a conversion after it happens. Rejected. By the time a transaction exists to inspect, the typed characters have already been consumed into the deleted range the conversion rule computed (tr.deleteRange(start, end), index.ts:571); vetoing the whole transaction would also veto the character insertion the transaction was built from, so the space after # would be lost along with the conversion.
A handleTextInput pre-empt limited to the block conversion plugin. Rejected. Every mark's own input rule, bold, italic, a color, and every other extension's input rules run through the very same collected plugin (index.ts:458-478), so a pre-empt that stopped handleTextInput before that plugin ran would also silence a caption's own mark input rules, which have nothing to do with block conversion and are supposed to keep working there exactly as 0014 already promises ("styles, links, and the formatting toolbar all work as they do in any other inline content").
Naming each conversion rule and shortcut by its key, one at a time, in a list this module owns. Rejected. It is not generic: a registered block whose own factory adds a conversion rule of its own (unlikely today, since no block in this codebase does, but not ruled out by anything in the contract) would need this module's list extended by hand, which kills nothing the way reading every specification's own extensions array does automatically. It would also need updating every time an upstream release adds or renames a shortcut, where the wrapper here needs no change because it never has to know what a rule or a shortcut is named.
Consequences¶
A block a Drupal.blocknote.alterEditorOptions() alter forces into the schema outside buildClientSchema()'s own assembled blockSpecs (an alter may replace options.schema entirely, per docs/extending.md's "Altering the editor's options") is off this contract and is not guarded: guardCaptionConversions() only ever sees the specifications this module itself assembles before BlockNoteSchema.create() is called, and an alter that replaces the schema afterward is, by 0014's own reasoning about the caption declaration, already responsible for keeping its replacement schema coherent.
The caption block's own specification is left unwrapped, deliberately: none of the module's registered caption blocks declares a conversion rule of its own, and wrapping a caption block's extensions against itself would only ever refuse a rule while the cursor sits in the very block that rule belongs to, which is not a case this record's defect describes.
Every registered block, default or extension-contributed, now carries a second layer around its extensions array whenever at least one caption block is registered; an editor whose schema holds no caption block pays nothing, because guardCaptionConversions() returns the specifications unchanged when the caption set is empty.
js/src/caption-guard.ts's draft for TypeCellOS/BlockNote, docs/drafts/upstream-blocknote-caption-conversion-guard.md, proposes a per-specification opt-out so a host does not need to wrap every other block's extensions from the outside the way this record does.
Amended 2026-09-20. A printable character typed while a caption block is node-selected, the selection js/src/portable/blockselect.ts makes on a plain click and js/src/portable/caption-keys.ts makes on the routes the amended Decision above adds, replaces the block with a paragraph holding the character, ProseMirror's own default for typing over a NodeSelection, shared with upstream's own file and image blocks, one undo away; this record does not change that, and it is recorded as a consequence of 0019's own click-to-select decision rather than fixed here.
References¶
node_modules/@blocknote/core/src/editor/managers/ExtensionManager/index.ts (lines 458-478, 481-514, 556-574), src/editor/BlockNoteExtension.ts (originalFactorySymbol), src/util/topo-sort.ts, src/blocks/Heading/block.ts (21-38, 166-188), src/blocks/Quote/block.ts (56-99), src/blocks/ListItem/BulletListItem/block.ts (83-127), src/blocks/ListItem/NumberedListItem/block.ts (93-141), src/blocks/ListItem/CheckListItem/block.ts (132-182), src/blocks/ListItem/ToggleListItem/block.ts (102-128), src/blocks/Code/block.ts (11, 50-56), src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts (101-119), src/blocks/Divider/block.ts (36-49), src/blocks/Paragraph/block.ts (57-80).
node_modules/@blocknote/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx (139-206).
node_modules/@handlewithcare/prosemirror-inputrules/dist/inputrules.js (run()).
node_modules/@blocknote/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts (Backspace 44-64, Delete, Enter 862-940).
This module: js/src/caption-guard.ts, js/src/portable/caption-keys.ts, js/src/schema.tsx, js/src/runtime.ts (captionsAmong()), js/src/editor.tsx (captionBlockTypes()), js/src/portable/blockselect.ts, js/src/toolbar.tsx (PolicyBlockTypeSelect).
Tests: js/src/__tests__/caption-guard.test.ts, js/src/__tests__/caption-keys.test.ts, js/src/__tests__/toolbar-block-type-select.test.tsx, tests/src/FunctionalJavascript/CaptionEditingTest.php.
0014.