Skip to content

Commit 6195bf7

Browse files
nperez0111claude
andcommitted
feat(core): drop disallowed marks instead of deleting plain blocks
When a block's content type changes from "inline" to "plain" (e.g. code blocks), old documents carry formatting marks the plain node no longer allows. Previously these caused data loss: y-prosemirror deletes the whole block on reconstruction, and BlockNote-JSON rehydration threw on createChecked. Instead of mutating the source (the removed stripDisallowedMarks pre-sync rule), leave the marks in place and never materialize the disallowed ones: - Yjs path: DropDisallowedMarksExtension wraps schema.node to filter each text child's marks through NodeType.allowedMarks during reconstruction. - Write path: styledTextToNodes drops disallowed marks for plain blocks before building the node, fixing initialContent / insert / replace / updateBlock / exporters. Both are scoped to plain-content blocks via the shared isPlainContentNodeType helper, so inline blocks keep their formatting. The read path already strips via textContent. Comment/suggestion (annotation) marks stay allowed and are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09aa403 commit 6195bf7

11 files changed

Lines changed: 372 additions & 302 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
4+
import { nodeToBlock } from "./nodeToBlock.js";
5+
import { blockToNode } from "./blockToNode.js";
6+
7+
/**
8+
* @vitest-environment jsdom
9+
*/
10+
11+
// Backwards compatibility for the "plain" content migration on the NON-Yjs
12+
// (BlockNote JSON) path. A block whose content type changed from "inline"
13+
// (formatting marks allowed) to "plain" (only the "annotation" group allowed)
14+
// may appear in stored BlockNote JSON with formatting styles on its text. The
15+
// write path must drop those disallowed marks instead of throwing when the node
16+
// is assembled (`createChecked`), and the change must be scoped to plain blocks
17+
// so inline blocks still keep their formatting.
18+
19+
// Old BlockNote JSON: a (now-plain) code block carrying a bold style.
20+
const legacyBoldCodeBlock = {
21+
type: "codeBlock" as const,
22+
props: { language: "javascript" },
23+
content: [
24+
{ type: "text" as const, text: "const x = 1;", styles: { bold: true } },
25+
],
26+
};
27+
28+
describe("blockToNode: plain-block backwards compatibility (write path)", () => {
29+
it("does not throw and drops formatting when converting a plain block with styles", () => {
30+
const editor = BlockNoteEditor.create();
31+
32+
expect(() =>
33+
blockToNode(legacyBoldCodeBlock as any, editor.pmSchema),
34+
).not.toThrow();
35+
36+
const node = blockToNode(legacyBoldCodeBlock as any, editor.pmSchema);
37+
// The node is valid (would otherwise fail this check).
38+
expect(() => node.check()).not.toThrow();
39+
40+
// Round-trips back to a plain string with no materialized formatting.
41+
const block = nodeToBlock(node, editor.pmSchema);
42+
expect(block.type).toBe("codeBlock");
43+
expect(block.content).toBe("const x = 1;");
44+
45+
editor._tiptapEditor.destroy();
46+
});
47+
48+
it("keeps formatting on an inline block (scoped to plain blocks only)", () => {
49+
const editor = BlockNoteEditor.create();
50+
51+
const node = blockToNode(
52+
{
53+
type: "paragraph",
54+
content: [{ type: "text", text: "hello", styles: { bold: true } }],
55+
} as any,
56+
editor.pmSchema,
57+
);
58+
59+
const block = nodeToBlock(node, editor.pmSchema);
60+
expect(block.type).toBe("paragraph");
61+
// Paragraph is "inline" content, so bold survives.
62+
expect(Array.isArray(block.content)).toBe(true);
63+
expect((block.content as any[])[0].styles.bold).toBe(true);
64+
65+
editor._tiptapEditor.destroy();
66+
});
67+
68+
it("mounts an editor with legacy initialContent without throwing", () => {
69+
const editor = BlockNoteEditor.create({
70+
initialContent: [legacyBoldCodeBlock as any],
71+
});
72+
editor.mount(document.createElement("div"));
73+
74+
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
75+
expect(editor.document[0].content).toBe("const x = 1;");
76+
77+
editor._tiptapEditor.destroy();
78+
});
79+
80+
it("read path already strips marks: nodeToBlock yields a bare string", () => {
81+
const editor = BlockNoteEditor.create();
82+
83+
// Build the node leniently and confirm the read path emits no styles even if
84+
// the block content is plain text.
85+
const node = blockToNode(legacyBoldCodeBlock as any, editor.pmSchema);
86+
const block = nodeToBlock(node, editor.pmSchema);
87+
expect(typeof block.content).toBe("string");
88+
89+
editor._tiptapEditor.destroy();
90+
});
91+
});

‎packages/core/src/api/nodeConversions/blockToNode.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { getColspan, isPartialTableCell } from "../../util/table.js";
2020
import { UnreachableCaseError } from "../../util/typescript.js";
2121
import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js";
22-
import { getStyleSchema } from "../pmUtil.js";
22+
import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js";
2323

2424
/**
2525
* Convert a StyledText inline element to a
@@ -52,11 +52,23 @@ function styledTextToNodes<T extends StyleSchema>(
5252
}
5353
}
5454

55+
// Backwards compat: old BlockNote JSON may carry formatting marks (e.g. bold)
56+
// on a block whose content type is now "plain". Those marks aren't allowed on
57+
// the node, and would make `createChecked` throw when the block is assembled.
58+
// Drop them here (for plain blocks only) — comment/suggestion (annotation)
59+
// marks are allowed and kept by `allowedMarks`.
60+
const allowedMarks =
61+
blockType &&
62+
schema.nodes[blockType] &&
63+
isPlainContentNodeType(schema, schema.nodes[blockType])
64+
? [...schema.nodes[blockType].allowedMarks(marks)]
65+
: marks;
66+
5567
const parseHardBreaks = !blockType || !schema.nodes[blockType].spec.code;
5668

5769
if (!parseHardBreaks) {
5870
return styledText.text.length > 0
59-
? [schema.text(styledText.text, marks)]
71+
? [schema.text(styledText.text, allowedMarks)]
6072
: [];
6173
}
6274

@@ -72,7 +84,7 @@ function styledTextToNodes<T extends StyleSchema>(
7284
if (text === "\n") {
7385
return schema.nodes["hardBreak"].createChecked();
7486
} else {
75-
return schema.text(text, marks);
87+
return schema.text(text, allowedMarks);
7688
}
7789
})
7890
);

‎packages/core/src/api/pmUtil.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Node, Schema } from "prosemirror-model";
1+
import type { Node, NodeType, Schema } from "prosemirror-model";
22
import { Transform } from "prosemirror-transform";
33
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
44
import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js";
@@ -52,3 +52,20 @@ export function getStyleSchema<S extends StyleSchema>(schema: Schema): S {
5252
export function getBlockCache(schema: Schema) {
5353
return getBlockNoteEditor(schema).blockCache;
5454
}
55+
56+
/**
57+
* Whether `nodeType` is a BlockNote block whose content type is `"plain"` — i.e.
58+
* it holds unstyled text and only allows the non-formatting (`"annotation"`)
59+
* marks. Resolved semantically from the block schema (the source of truth),
60+
* reachable from `schema.cached.blockNoteEditor`.
61+
*
62+
* Returns `false` for every non-block / structural node type (`doc`,
63+
* `blockGroup`, `text`, inline content, table sub-nodes), since those aren't
64+
* keys in the block schema.
65+
*/
66+
export function isPlainContentNodeType(
67+
schema: Schema,
68+
nodeType: NodeType,
69+
): boolean {
70+
return getBlockSchema(schema)[nodeType.name]?.content === "plain";
71+
}

‎packages/core/src/editor/BlockNoteEditor.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,10 @@ export class BlockNoteEditor<
532532
);
533533
}
534534
const schema = getSchema(tiptapOptions.extensions!);
535+
// `blockToNode` (via `isPlainContentNodeType`) resolves the block schema
536+
// through `schema.cached.blockNoteEditor`, so stamp it on this throwaway
537+
// schema now — the real `pmSchema` is stamped separately below.
538+
schema.cached.blockNoteEditor = this;
535539
const pmNodes = initialContent.map((b) =>
536540
blockToNode(b, schema, this.schema.styleSchema).toJSON(),
537541
);

0 commit comments

Comments
 (0)