From f596d8a38d888a32d438e95ee9d9d2fdd583e2ac Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 16 Jul 2026 16:14:01 +0200 Subject: [PATCH 1/3] Fixed plain content issues --- .../src/api/nodeConversions/nodeToBlock.ts | 3 ++ packages/core/src/schema/index.ts | 1 + .../src/schema/inlineContent/createSpec.ts | 53 +++++++++++++++++-- .../core/src/schema/inlineContent/internal.ts | 9 +++- packages/react/src/schema/ReactBlockSpec.tsx | 8 +-- .../src/schema/ReactInlineContentSpec.tsx | 18 +++++-- 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 78e616b832..7cdc6da8c9 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -371,6 +371,9 @@ export function nodeToCustomInlineContent< inlineContentSchema, styleSchema, ) as any; // TODO: is this safe? could we have Links here that are undesired? + } else if (icConfig.content === "plain") { + // Plain inline content is a single unstyled string. + content = node.textContent as any; } else { content = undefined; } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 05585ab28b..2f1e703007 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,6 +4,7 @@ export * from "./blocks/types.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; +export * from "./markGroups.js"; export * from "./propTypes.js"; export * from "./styles/createSpec.js"; export * from "./styles/internal.js"; diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index fa560f91d7..37076b8530 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -11,6 +11,7 @@ import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { propsToAttributes } from "../blocks/internal.js"; +import { NON_FORMATTING_MARK_GROUP } from "../markGroups.js"; import { Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { @@ -148,6 +149,26 @@ function parseInlineContent(el: HTMLElement, schema: Schema) { }).content; } +// Flattens parsed inline content into text nodes only. "plain" inline content +// holds text only, so non-text inline nodes are flattened: line breaks become +// newline characters and other nodes (e.g. mentions) are kept as their text. +function flattenToText(content: Fragment, schema: Schema) { + const textNodes: ProsemirrorNode[] = []; + content.forEach((child) => { + if (child.isText) { + textNodes.push(child); + } else { + const text = + child.type === schema.linebreakReplacement ? "\n" : child.textContent; + if (text) { + textNodes.push(schema.text(text, child.marks)); + } + } + }); + + return Fragment.fromArray(textNodes); +} + export function getInlineContentParseRules( config: C, customParseFunction?: CustomInlineContentImplementation["parse"], @@ -165,7 +186,8 @@ export function getInlineContentParseRules( // element whose children to parse as a fallback when `parseContent` returns // `undefined`. const getContent = - customParseContentFunction && config.content === "styled" + customParseContentFunction && + (config.content === "styled" || config.content === "plain") ? (resolveContentElement: (el: HTMLElement) => HTMLElement) => (node: HTMLElement, schema: Schema) => { const result = customParseContentFunction({ el: node, schema }); @@ -173,10 +195,18 @@ export function getInlineContentParseRules( // `parseContent` may return `undefined` to fall through to the // default inline content parsing. if (result !== undefined) { - return result; + return config.content === "plain" + ? flattenToText(result, schema) + : result; } - return parseInlineContent(resolveContentElement(node), schema); + const parsed = parseInlineContent( + resolveContentElement(node), + schema, + ); + return config.content === "plain" + ? flattenToText(parsed, schema) + : parsed; } : undefined; @@ -230,10 +260,23 @@ export function createInlineContentSpec< inline: true, group: "inline", draggable: inlineContentImplementation.meta?.draggable, - selectable: inlineContentConfig.content === "styled", + selectable: inlineContentConfig.content !== "none", atom: inlineContentConfig.content === "none", code: inlineContentImplementation.meta?.code, - content: inlineContentConfig.content === "styled" ? "inline*" : "", + content: + inlineContentConfig.content === "styled" + ? "inline*" + : inlineContentConfig.content === "plain" + ? "text*" + : "", + // "plain" inline content holds unstyled text, so it disallows formatting + // marks (mirroring "plain" blocks). It still allows the non-formatting marks + // (comments and suggestions/diffs), which annotate content without changing + // it and are ignored by the content model. + marks: + inlineContentConfig.content === "plain" + ? NON_FORMATTING_MARK_GROUP + : undefined, addAttributes() { return propsToAttributes(inlineContentConfig.propSchema); diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index f292fcb73b..ab283a674d 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -105,7 +105,12 @@ export function createInlineContentSpecFromTipTapNode< { type: node.name as T["name"], propSchema, - content: node.config.content === "inline*" ? "styled" : "none", + content: + node.config.content === "inline*" + ? "styled" + : node.config.content === "text*" + ? "plain" + : "none", }, // Cast needed because `implementation` is typed against the generic // `CustomInlineContentConfig`, while `createInternalInlineContentSpec` @@ -117,7 +122,7 @@ export function createInlineContentSpecFromTipTapNode< } as unknown as InlineContentImplementation<{ type: T["name"]; propSchema: P; - content: "styled" | "none"; + content: "styled" | "none" | "plain"; }>, ); } diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index f7e8c49fad..1edc25a1ce 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -33,7 +33,7 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" +} & (Config["content"] extends "inline" | "plain" ? { contentRef: (node: HTMLElement | null) => void; } @@ -133,7 +133,7 @@ export function BlockContentWrapper< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | "plain", const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfig, @@ -159,7 +159,7 @@ export function createReactBlockSpec< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | "plain", const BlockConf extends BlockConfig, const TOptions extends Partial>, >( @@ -188,7 +188,7 @@ export function createReactBlockSpec< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | "plain", const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfigOrCreator, diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index e6db968537..47c2117c47 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -13,6 +13,7 @@ import { InlineContentSchemaWithInlineContent, InlineContentSpec, inlineContentToNodes, + NON_FORMATTING_MARK_GROUP, nodeToCustomInlineContent, PartialCustomInlineContentFromConfig, Props, @@ -113,7 +114,8 @@ export function InlineContentWrapper< * rendering. * * @param inlineContentConfig - The inline content type configuration, including - * its `type` name, `propSchema`, and `content` mode (`"styled"` or `"none"`). + * its `type` name, `propSchema`, and `content` mode (`"styled"`, `"plain"`, or + * `"none"`). * @param inlineContentImplementation - The React implementation, including a * `render` component and optionally a `toExternalHTML` component and `parse` * rules. @@ -134,13 +136,23 @@ export function createReactInlineContentSpec< name: inlineContentConfig.type as T["type"], inline: true, group: "inline", - selectable: inlineContentConfig.content === "styled", + selectable: inlineContentConfig.content !== "none", atom: inlineContentConfig.content === "none", draggable: inlineContentImplementation.meta?.draggable, code: inlineContentImplementation.meta?.code, content: (inlineContentConfig.content === "styled" ? "inline*" - : "") as T["content"] extends "styled" ? "inline*" : "", + : inlineContentConfig.content === "plain" + ? "text*" + : "") as T["content"] extends "styled" ? "inline*" : "", + // "plain" inline content holds unstyled text, so it disallows formatting + // marks (mirroring "plain" blocks). It still allows the non-formatting marks + // (comments and suggestions/diffs), which annotate content without changing + // it and are ignored by the content model. + marks: + inlineContentConfig.content === "plain" + ? NON_FORMATTING_MARK_GROUP + : undefined, addAttributes() { return propsToAttributes(inlineContentConfig.propSchema); From 9b1ca65594d5ead35e20c1d1a2e15b32f4de7ae9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 16 Jul 2026 17:22:53 +0200 Subject: [PATCH 2/3] Added tests & additional fixes --- .../src/api/nodeConversions/blockToNode.ts | 6 +- packages/core/src/api/pmUtil.ts | 24 ++- .../schema/inlineContent/createSpec.test.ts | 151 ++++++++++++++++++ .../src/schema/inlineContent/createSpec.ts | 10 +- packages/react/src/schema/ReactBlockSpec.tsx | 7 +- 5 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/schema/inlineContent/createSpec.test.ts diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 61bc44d68a..af5c0ba1b7 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -64,7 +64,11 @@ function styledTextToNodes( ? [...schema.nodes[blockType].allowedMarks(marks)] : marks; - const parseHardBreaks = !blockType || !schema.nodes[blockType].spec.code; + // Plain content nodes hold raw text — including newlines — + // rather than inline content, so they can't contain `hardBreak` nodes. Keep + // newlines as text characters for them instead of splitting into hard breaks. + const parseHardBreaks = + !blockType || !isPlainContentNodeType(schema, schema.nodes[blockType]); if (!parseHardBreaks) { return styledText.text.length > 0 diff --git a/packages/core/src/api/pmUtil.ts b/packages/core/src/api/pmUtil.ts index bb427a3710..17ed2aa943 100644 --- a/packages/core/src/api/pmUtil.ts +++ b/packages/core/src/api/pmUtil.ts @@ -54,18 +54,26 @@ export function getBlockCache(schema: Schema) { } /** - * Whether `nodeType` is a BlockNote block whose content type is `"plain"` — i.e. - * it holds unstyled text and only allows the non-formatting (`"annotation"`) - * marks. Resolved semantically from the block schema (the source of truth), - * reachable from `schema.cached.blockNoteEditor`. + * Whether `nodeType` is a BlockNote block or inline content whose content type + * is `"plain"` — i.e. it holds unstyled text and only allows the non-formatting + * (`"annotation"`) marks. Resolved semantically from the block / inline content + * schema (the source of truth), reachable from `schema.cached.blockNoteEditor`. * - * Returns `false` for every non-block / structural node type (`doc`, - * `blockGroup`, `text`, inline content, table sub-nodes), since those aren't - * keys in the block schema. + * Returns `false` for every other node type (`doc`, `blockGroup`, `text`, table + * sub-nodes, and non-plain blocks / inline content), since those aren't plain + * content keys in either schema. */ export function isPlainContentNodeType( schema: Schema, nodeType: NodeType, ): boolean { - return getBlockSchema(schema)[nodeType.name]?.content === "plain"; + if (getBlockSchema(schema)[nodeType.name]?.content === "plain") { + return true; + } + + const inlineContentConfig = getInlineContentSchema(schema)[nodeType.name]; + return ( + typeof inlineContentConfig === "object" && + inlineContentConfig.content === "plain" + ); } diff --git a/packages/core/src/schema/inlineContent/createSpec.test.ts b/packages/core/src/schema/inlineContent/createSpec.test.ts new file mode 100644 index 0000000000..c659a2ccac --- /dev/null +++ b/packages/core/src/schema/inlineContent/createSpec.test.ts @@ -0,0 +1,151 @@ +import { DOMParser as PMDOMParser } from "@tiptap/pm/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { + defaultBlockSpecs, + defaultInlineContentSpecs, +} from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createInlineContentSpec } from "./createSpec.js"; + +// A minimal "plain" inline content, matched from external HTML by a `parse` +// function (the `tag: "*"` parse rule). +const customPlainIC = createInlineContentSpec( + { + type: "customPlainIC", + propSchema: {}, + content: "plain", + }, + { + parse: (el) => (el.classList?.contains("custom-plain-ic") ? {} : undefined), + render: () => { + const dom = document.createElement("span"); + const contentDOM = document.createElement("span"); + dom.append(contentDOM); + return { dom, contentDOM }; + }, + }, +); + +const createEditor = () => + BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: defaultBlockSpecs, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + customPlainIC, + }, + }), + }); + +describe("plain inline content", () => { + it("holds its text as a string in the block model", () => { + const editor = createEditor(); + + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: [ + { + type: "customPlainIC", + props: {}, + content: "hello world", + } as any, + ], + }, + ]); + + const inlineContent = (editor.document[0] as any).content[0]; + expect(inlineContent.type).toBe("customPlainIC"); + expect(inlineContent.content).toBe("hello world"); + + editor._tiptapEditor.destroy(); + }); + + it("round-trips a newline in its string content", () => { + const editor = createEditor(); + + // Plain content holds raw text, so newlines are kept as characters rather + // than being split into (disallowed) `hardBreak` nodes. + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: [ + { + type: "customPlainIC", + props: {}, + content: "first\nsecond", + } as any, + ], + }, + ]); + + const inlineContent = (editor.document[0] as any).content[0]; + expect(inlineContent.type).toBe("customPlainIC"); + expect(inlineContent.content).toBe("first\nsecond"); + + editor._tiptapEditor.destroy(); + }); + + it("keeps text and drops formatting marks when parsed", () => { + const editor = createEditor(); + + const blocks = editor.tryParseHTMLToBlocks( + `

hello world

`, + ); + + const inlineContent = (blocks[0] as any).content[0]; + expect(inlineContent.type).toBe("customPlainIC"); + expect(inlineContent.content).toBe("hello world"); + + editor._tiptapEditor.destroy(); + }); + + it("converts line breaks to newline characters when parsed", () => { + const editor = createEditor(); + + const blocks = editor.tryParseHTMLToBlocks( + `

first
second

`, + ); + + const inlineContent = (blocks[0] as any).content[0]; + expect(inlineContent.type).toBe("customPlainIC"); + expect(inlineContent.content).toBe("first\nsecond"); + + editor._tiptapEditor.destroy(); + }); + + it("disallows formatting marks but allows annotation marks", () => { + const editor = createEditor(); + + // Checked at the ProseMirror level because the block model intentionally + // represents plain content as a bare string, without marks. + const container = document.createElement("div"); + container.innerHTML = `

hello bold inserted

`; + const parsed = PMDOMParser.fromSchema(editor.pmSchema).parse(container, { + topNode: editor.pmSchema.nodes["blockGroup"].create(), + }); + + let plainIC: any; + parsed.descendants((node) => { + if (node.type.name === "customPlainIC") { + plainIC = node; + return false; + } + return true; + }); + + expect(plainIC).toBeDefined(); + expect(plainIC.textContent).toBe("hello bold inserted"); + + const markNames = new Set(); + plainIC.forEach((child: any) => { + child.marks.forEach((mark: any) => markNames.add(mark.type.name)); + }); + expect(markNames.has("insertion")).toBe(true); + expect(markNames.has("bold")).toBe(false); + + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index 37076b8530..8b36c21ab8 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -185,12 +185,16 @@ export function getInlineContentParseRules( // `parse` function (the second rule). `resolveContentElement` locates the // element whose children to parse as a fallback when `parseContent` returns // `undefined`. + // "plain" inline content always needs `getContent` so its parsed content is + // flattened to text (`
`/line breaks become newline characters), regardless + // of whether a custom `parseContent` is provided — mirroring "plain" blocks. + // "styled" inline content only needs it to run a custom `parseContent`. const getContent = - customParseContentFunction && - (config.content === "styled" || config.content === "plain") + config.content === "plain" || + (customParseContentFunction && config.content === "styled") ? (resolveContentElement: (el: HTMLElement) => HTMLElement) => (node: HTMLElement, schema: Schema) => { - const result = customParseContentFunction({ el: node, schema }); + const result = customParseContentFunction?.({ el: node, schema }); // `parseContent` may return `undefined` to fall through to the // default inline content parsing. diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1edc25a1ce..c32bcac54e 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -63,11 +63,8 @@ export type ReactCustomBlockImplementation< }; export type ReactCustomBlockSpec< - B extends BlockConfig = BlockConfig< - string, - PropSchema, - "inline" | "none" - >, + B extends BlockConfig = + BlockConfig, > = { config: B; implementation: ReactCustomBlockImplementation; From b88c124bad80cd822b66b2bcefd5517224f91238 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 16 Jul 2026 18:44:49 +0200 Subject: [PATCH 3/3] Updated tests --- .../src/block/createReactDiagramBlockSpec.test.tsx | 9 ++++++++- .../parse/__snapshots__/html/codeBlocksNestedHTML.json | 2 +- tests/src/unit/core/schema/__snapshots__/blocks.json | 2 +- .../__snapshots__/blocknoteHTML/diagram/basic.html | 3 +-- .../export/__snapshots__/html/diagram/basic.html | 3 +-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx index 3588ca7cde..9880c30d6d 100644 --- a/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx @@ -156,9 +156,16 @@ describe("Diagram block source popup", () => { await flush(); // The selection spans exactly the block's source, not the document. + // Newlines are hard break nodes in the ProseMirror doc, so extract them as + // "\n" to match the block's source (where `getBlock` renders them as such). const selection = editor.prosemirrorState.selection; expect( - editor.prosemirrorState.doc.textBetween(selection.from, selection.to), + editor.prosemirrorState.doc.textBetween( + selection.from, + selection.to, + undefined, + (leafNode) => (leafNode.type.name === "hardBreak" ? "\n" : ""), + ), ).toBe(source()); expect(isPopupOpen()).toBe(true); }); diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksNestedHTML.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksNestedHTML.json index e6480d3f5b..8a025ee12a 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksNestedHTML.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksNestedHTML.json @@ -27,7 +27,7 @@ line two", ], "id": "2", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 4cb600a13b..ee48987244 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -128,12 +128,12 @@ }, "extensions": [ [Function], - [Function], ], "implementation": { "meta": { "code": true, "defining": true, + "highlight": [Function], "isolating": false, }, "node": null, diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html index 608a912f53..e07e7c44b4 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html @@ -32,8 +32,7 @@
-                graph TD
-  A[Start] --> B[End]
+                graph TD
A[Start] --> B[End]
- graph TD - A[Start] --> B[End] + graph TD
A[Start] --> B[End]
\ No newline at end of file