Skip to content

Typing # or a block shortcut in a custom block's inline content replaces the block and loses its props #3278

Open

Description

@NodarDavituri

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-1 also in the middle): Mod-Alt-1 (heading), Mod-Alt-0 (paragraph), Mod-Alt-q (quote), Mod-Alt-c (code block, from 0.57.0), and Mod-Shift-8, Mod-Shift-7, Mod-Shift-9 and Mod-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} />;
}
  1. Click just before the "C" of "Caption", so the caret is at the start of the captioned block's text.
  2. 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, beside resetsTo, for example merges: "never", read by mergeBlocksCommand() 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 in insertOrUpdateBlockForSlashMenu() 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 in experimental_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 reading content !== "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 resetsTo naming 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 through getMergeContent() (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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions