Repository navigation
Description
What’s broken?
A custom block whose content is "inline" cannot keep its type and props against the editor's generic conversions and merges. Some blocks hold inline content that is a caption for something the block exists to hold, such as a reference kept in a prop, rather than the text the block exists to hold, as a paragraph does. Both are declared content: "inline", so nothing generic can tell them apart. No block BlockNote ships is built that way (the file and image blocks' captions are a string prop on a content: "none" block), so the steps below register a small custom block for it.
experimental_keyboard (0.56.0) already lets such a block protect itself from two routes, and both work in the reproduction: with enter: "line-break", Enter inserts a line break instead of splitting the block, and with resetsTo naming its own type, Backspace at its start no longer turns it into a paragraph. Every other route still replaces or removes the block:
- The markdown input rules, typed at the start of its text, convert it to another type and drop its props. Of those tried,
#makes a heading,-a bullet list item,1.a numbered list item,[ ]a check list item,>a quote.---makes a divider and three backticks and a space make a code block, and these two drop the text as well. - Every block type shortcut converts it the same way (tried with the caret at the start of its text, and for
Mod-Alt-1also in the middle):Mod-Alt-1(heading),Mod-Alt-0(paragraph),Mod-Alt-q(quote),Mod-Alt-c(code block, from 0.57.0), andMod-Shift-8,Mod-Shift-7,Mod-Shift-9andMod-Shift-6(bullet, numbered, check and toggle list items). - Picking "Heading 1" from the slash menu while the block's text is empty converts it in place and drops its props.
- Backspace at the start of its text, and Delete at the end of the block above it, merge its text into the block above and remove the block with its props. When the block above is an empty paragraph, that paragraph takes the text instead. Backspace in the block while it is empty removes it.
- Delete at the end of its text, or in it while it is empty, pulls the next block's text into it and removes the next block.
Wherever it was tried (listed under Steps to reproduce), one undo straight afterward restores the block with its props; after an input rule, the typed characters come back as text. Without that undo, the prop is gone from the document.
What did you expect to happen?
That a block whose own inline content is not body text can keep its type and props. Typing # , or any other markdown prefix, at the start of its text leaves the characters there as text, as it already does in a code block and in a table cell; a block type shortcut does nothing there; and Backspace and Delete at its edges neither merge it into a neighbor, nor take a neighbor's text in, nor remove it. If that cannot be the default for every content: "inline" block, then a setting declared once on the block, beside experimental_keyboard's enter and resetsTo, that closes these routes (see Additional context).
Steps to reproduce
Use this as src/App.tsx in the minimal example, in place of the starter sandbox's own. It registers one custom block, captioned, with inline content, one string prop, ref, and the keyboard settings 0.56.0 added, and opens a document holding a paragraph, a captioned block whose ref is media:42, and another paragraph.
import { BlockNoteSchema } from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react";
// Inline content that captions a reference held in a prop.
const Captioned = createReactBlockSpec(
{ type: "captioned", content: "inline", propSchema: { ref: { default: "" } } },
{
experimental_keyboard: { enter: "line-break", resetsTo: { type: "captioned" } },
render: ({ block, contentRef }) => (
<figure>
<code contentEditable={false}>ref: {block.props.ref}</code>
<figcaption ref={contentRef} />
</figure>
),
},
);
const schema = BlockNoteSchema.create().extend({
blockSpecs: { captioned: Captioned() },
});
export default function App() {
const editor = useCreateBlockNote({
schema,
initialContent: [
{ type: "paragraph", content: "Before" },
{ type: "captioned", props: { ref: "media:42" }, content: "Caption" },
{ type: "paragraph", content: "After" },
],
});
(window as any).editor = editor; // inspect editor.document in the console
return <BlockNoteView editor={editor} />;
}- Click just before the "C" of "Caption", so the caret is at the start of the
captionedblock's text. - Type
#and a space.
Actual: the block turns into a level 1 heading holding "Caption", and the ref: media:42 line disappears. In editor.document the block is { type: "heading", props: { backgroundColor: "default", textColor: "default", textAlignment: "left", level: 1, isToggleable: false } }, with no ref.
Every other route, each from a freshly loaded page:
| Route | Result |
|---|---|
- , 1. , [ ] , > at the start of "Caption" |
bullet, numbered, check list item, quote; text kept, ref gone |
--- at the start of "Caption" |
divider; text and ref gone |
| Three backticks and a space at the start of "Caption" | empty code block, language: "text"; text and ref gone |
Mod-Alt-1, caret at the start or in the middle of "Caption" |
heading; text kept, ref gone |
Mod-Alt-0, Mod-Alt-q, Mod-Alt-c |
paragraph, quote, code block; text kept, ref gone (Mod-Alt-c does nothing on 0.56.0) |
Mod-Shift-8, Mod-Shift-7, Mod-Shift-9, Mod-Shift-6 |
bullet, numbered, check, toggle list item; text kept, ref gone |
Empty caption, /heading typed, Enter (Heading 1) |
empty heading; ref gone |
| Backspace at the start of "Caption" | one paragraph, "BeforeCaption"; captioned block gone |
| Backspace at the start of "Caption", with the paragraph above empty | that paragraph, same id, now holds "Caption"; captioned block gone |
| Delete at the end of "Before" | one paragraph, "BeforeCaption"; captioned block gone |
| Delete at the end of "Caption" | captioned holds "CaptionAfter", ref kept; the "After" paragraph gone |
| Empty caption, Backspace | captioned block gone; caret at the end of "Before" |
| Empty caption, Delete | captioned holds "After", ref kept; the "After" paragraph gone |
| Enter at the start, in the middle or at the end of "Caption", or in an empty caption | line break inserted; block and ref kept |
Backspace at the start of "Caption", with the captioned block first in the document |
nothing changes |
Mod-Z straight after the conversion or merge restored the captioned block with ref: media:42 in every route tried (# , ---, the code fence, Mod-Alt-1, the slash menu, Backspace's merge and both of Delete's merges); after an input rule the characters come back as text, as in "# Caption". For comparison, # at the start of a code block's text and of a table cell's text stays text, and the same block registered without experimental_keyboard splits on Enter (its second half becoming a paragraph) and, as the first block, turns into a paragraph on Backspace at its start.
BlockNote version
0.57.0 (@blocknote/core, @blocknote/react and @blocknote/mantine). 0.56.0 behaves the same, except that Mod-Alt-c does nothing there.
Environment
Google Chrome 156.0.8078.76, headless, driven by Playwright 1.62.0-alpha, on macOS 26.4; React 19.3.1, @mantine/core 9.7.4, Vite 8.3.5. Mod is Cmd there, so Mod-Alt-1 was Cmd-Option-1. Not tried in Firefox or Safari, or on Linux or Windows.
Additional context
Suggested fix
A per-block way to opt out of every generic conversion and of the keymap's merges, so a host does not have to wrap every other block's extensions from the outside, and bind keys ahead of the default keymap, to protect one block's own inline content. In order of preference:
- A merge setting in
experimental_keyboard, besideresetsTo, for examplemerges: "never", read bymergeBlocksCommand()and by the Backspace and Delete branches that join or remove an inline block, so a block can refuse to be merged into a neighbor or to take a neighbor's content. What the key does instead, nothing or selecting the block, is the maintainers' choice; this project selects it. - A conversion setting next to it, for example
convertible: false, read by the central input rule check, by each block type shortcut's own check, and by the slash menu's in-place conversions, today's of an empty block ininsertOrUpdateBlockForSlashMenu()and fix(core): convert existing text via slash menu at block start #3276's if it lands, so one declaration on a block whose own inline content stands in for a reference closes every generic route for it, whether the block ships with the library or is registered by a host. Whether that belongs inexperimental_keyboard, which today is only about keys, or in the block's config is for the maintainers. - Failing that, exporting the check itself, such as
blockAcceptsGenericConversion(editor, blockType), so a host's own wrapper (the shape this project already carries) can call the same rule the built-in blocks would use instead of only readingcontent !== "inline"for itself; this would still leave the merges for a host to guard separately.
Where the conversion checks live
The central check sits where every input rule's replace() is applied, in getProsemirrorPluginsFromExtension(): a truthy result is applied whenever the block holding the cursor has content and its schema entry's content is "inline" (ExtensionManager/index.ts#L714-L729). Each block type shortcut repeats the same check inline rather than sharing it: the heading's Mod-Alt-<level> (Heading/block.ts#L31-L48), the quote's Mod-Alt-q (Quote/block.ts#L60-L75), the bullet list item's Mod-Shift-8 (BulletListItem/block.ts#L92-L107), the numbered list item's Mod-Shift-7 (NumberedListItem/block.ts#L123-L138), the check list item's Mod-Shift-9 (CheckListItem/block.ts#L141-L156), the toggle list item's Mod-Shift-6 (ToggleListItem/block.ts#L120-L135), the paragraph's own Mod-Alt-0 (Paragraph/block.ts#L61-L76) and the code block's Mod-Alt-c (CodeKeyboardShortcutsExtension.ts#L9-L23). The divider's input rule (Divider/block.ts#L39-L46) and the code block's fence rule (CodeKeyboardShortcutsExtension.ts#L117-L140) pass the same check and replace the block's content along with its type, which is why those two drop the text. The slash menu updates the block in place whenever its content is empty or only /, whatever the block's type (getDefaultSlashMenuItems.ts#L60-L97). None of these reads experimental_keyboard.
What experimental_keyboard reaches in the default keymap, and what it does not
The default keymap binds Backspace, Delete and Enter in KeyboardShortcutsExtension.ts (#L1033-L1036), and reads a block's settings through keyboardOf() (#L95-L100).
- Enter: covered. With
enter: "line-break", the hard break branch (#L712-L754) runs before any branch that splits the block. - Backspace's reset: covered. With
resetsTonaming the block's own type, the reset branch (#L111-L144) finds the block already in that form and passes. - Backspace's merge: not covered. The next branch (#L145-L182) calls
mergeBlocksCommand()(mergeBlocks.ts#L90), which decides on content kind alone throughgetMergeContent()(mergeBlocks.ts#L24-L31): inline content merges into inline content. In an empty block, that merge, or the branch that removes an empty inline block when merging is impossible (#L244-L291), removes it. - Delete's merges: not covered. Delete merges the next block in the same way (#L421-L454), whichever of the two is the caption.
What this project does meanwhile
This is the Drupal BlockNote module (https://www.drupal.org/project/blocknote), whose registered media blocks hold their caption as the block's own inline content (ADR 0014). Its guardCaptionConversions() (js/src/caption-guard.ts) wraps every other registered block's own extensions: an input rule's replace() is made to return undefined, and a keyboard shortcut's handler is made to return false, whenever editor.getTextCursorPosition().block.type is one of the block types the module knows to be captions. It is applied to the specifications the module's own schema assembles, so it cannot reach a caption on a block from outside its own registry. js/src/portable/caption-keys.ts binds Backspace, Delete and Enter ahead of KeyboardShortcutsExtension and node-selects the caption block instead of letting it be converted, merged or dropped. It was written against 0.54.2; from 0.56.0, experimental_keyboard can take over its Enter and reset halves, as the reproduction above shows, but not the merges.
AI use: I maintain the BlockNote module for Drupal. Its code, tests and documentation, this report and the reproduction behind it were made with AI coding agents that I direct.
Activity
NodarDavituri commentedon Nov 1, 2026
Two more routes found since filing that reach a block's props through the default keymap. Measured on 0.57.0 in jsdom with real keydown events, and confirmed by hand in Chrome 155 on macOS.
Enter with text selected from the start of a block splits it with its type and props on both halves. #550 made a bare caret at the start insert an empty block above, but a selection anchored at the start still deletes the text and splits with both
keepTypeandkeepPropsset (KeyboardShortcutsExtension.ts#L981-L1014). With "Heading" selected at the start of a level 2 heading "Heading text", Enter leaves an empty level 2 heading above one holding " text". Acaptionedblock registered as in the issue but withoutenter: "line-break"becomes two blocks with the sameref: "media:42"; Mod-A then Enter reaches it too. The issue's owncaptionedis not affected, since its line-break branch runs first.Backspace and Delete also join across nesting levels, not only between siblings. Delete looks for the first child (#L376-L420) and then for the next sibling of the nearest ancestor that has one (#L500-L545), and
mergeBlocksCommand()merges into the parent of a first child or the last descendant of the previous sibling (mergeBlocks.ts#L104-L121). Withcaptionedregistered exactly as in the issue:captionedcaptionedgonecaptionedafter "Parent"captionedgonecaptionedcaptionedholds "CaptionAfter",refkept; "After" goneA
mergessetting as the issue suggests would have to be read in all three places, against the block on either side of the join, nested or not.nperez0111 commentedon Nov 2, 2026
I'm not sure that I understand what the real issue is here. I think that you are trying to use a caption prop and are wanting to preserve that across operations. But I really don't think that is a good idea in the general case. Maybe a caption might be transferred over from the old block to the new in some cases. but I don't think the caption to a diagram is the same as the caption to audio and it should reset. If you want that caption to reset you can find other ways of doing it like editor.onChange/onBeforeChange and getChanges to figure out what changed put the change back https://www.blocknotejs.org/docs/reference/editor/events#onbeforechange
NodarDavituri commentedon Nov 3, 2026
Thanks for looking. I may have framed it badly: I'm not asking to keep a caption when a block changes type. I'm asking for a way to stop the type change from happening at all.
Take the captioned block from the issue: its inline content captions a reference held in a prop (ref: "media:42"). If the author types # at the start of the caption, presses Mod-Alt-1, or presses Backspace at its start, the whole block becomes a heading or merges into the block above. The reference is gone, and the media item drops out of the document. In a code block or a table cell, # stays as text. I'd like a custom block to be able to ask for the same.
onBeforeChangeworks as a stopgap, and I already use it for one block. But each custom block then has to recognize every route that can convert or merge it: the input rules, the type shortcuts, the slash menu, and Backspace/Delete across siblings and nesting levels. experimental_keyboard already lets a block opt out of two of those routes (enter: "line-break", resetsTo). One more setting there, closing the input rules, the type shortcuts and merges for that block, would cover the rest.edit: if a setting like this makes sense to you, I'm happy to open a PR.
nperez0111 commentedon Nov 3, 2026
Ok, so your issue is that you do not want input rules to run in this specific block type. Again, I'm not really clear on why. So, your issue is with wanting to add another condition to: https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/editor/managers/ExtensionManager/index.ts#L725 I could see having a block.spec.meta that disallows conversion or even have a callback to allow comparisons of your own.
Again, I'm asking you for a why, because I'm not sure that you are structuring your custom block properly if this is of concern for you. If the block has inline content, it is a text block, so it is meant to be compatible with any other text block in blocknote. I wonder whether you should actually be structuring this as two blocks that are separate so that the conversion of one does not affect the type of the other. But, I need a concrete example from you and not just a wall of AI text
NodarDavituri commentedon Nov 3, 2026
Here are the specifics:
We build the Drupal BlockNote module. Its media block shows an image or video from Drupal's media library, stored as a reference in a prop (
mediaUuid), with a caption under it. Drupal captions can hold formatted text like links and bold, so the caption is the block's inline content instead of a string prop like your image block's. On the page it renders as one<figure>with a<figcaption>.Today, if an author types
#at the start of that caption, the block becomes a heading. The image is gone from the document and only the caption text is left. Same with Backspace at the start of the caption, which merges it into the paragraph above.Two blocks wouldn't hold together: the author could drag the caption away from its image, delete one without the other, or turn the caption into something else, and the document would no longer say which caption belongs to which image. It's the same reason your image block keeps its caption inside the block (this is what the first paragraph of that wall of AI text was trying to say).
But the main point is that the module also lets other Drupal modules register blocks of their own, as it is common in Drupal ecosystem, and some of them will have the same shape: a gallery item, an embedded post, a quote card with its source.
Today we guard our own blocks with
onBeforeChangeand wrapped input rules and shortcuts, but every developer who builds a block on top of us would have to copy those guards and keep them in step with each BlockNote release. A setting the block declares once would let each block say "I'm not a text block to convert" and leave the rest to BlockNote.Anyway,
block.spec.metaflag that turns off conversions for the block, as you suggest, is exactly what I'm after. A callback would work too. It needs to cover the input rules, the type shortcuts and the Backspace/Delete merges. I'm happy to open a PR for the flag if you're OK with that shape.nperez0111 commentedon Nov 3, 2026
OK, I see the constraint here. In my opinion, the real problem is that you have a media block which has rich-text as it's caption.
I think that block conversion is only one of the issues that you are going to run into with this. Inline content within blocknote is meant to be interchangeable. It is not true in this case because you have built a block that is actually fundamentally incompatible in type, just because you needed the rich text property of blocknote for it.
Unfortunately, what you would really need here is to allow rich text as props to a block, but this is not easy to implement at all, so I cannot offer this to you as a solution that we could work towards within the year.
Personally, I would suggestion going in a different direction for this: a media block that is like our image block, but when rendering the caption, you could use a separate instance of prosemirror/blocknote in the view layer to show the caption editing as though it were inline to the editor. Obviously, there would be a disconnect between the caption editor and the rest of the editor content, but that is to be expected in this scenario. But, with that editor-in-editor you can serialize that state back and store it into the block's prop. This is a cleaner design and fits blocknote's model much better. I would personally serialize it to markdown to not preserve any non-essential formatting but I don't know your requirements for rich text within captions.
NodarDavituri commentedon Nov 3, 2026
Ok thanks, that makes sense.
For now we'll keep guarding caption blocks in the module, which covers the blocks other modules register through it as well, and we'll look at the editor-in-editor approach if the guards become too costly to maintain. I'll close this one.