Skip to content

Commit 1b53232

Browse files
authored
fix(ai): loosen serialization of blocks in columns (#2716) (#2718)
1 parent 9b441be commit 1b53232

8 files changed

Lines changed: 147 additions & 24 deletions

File tree

packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export function insertBlocks<
2626
const id =
2727
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
2828
const pmSchema = getPmSchema(tr);
29-
const nodesToInsert = blocksToInsert.map((block) =>
30-
blockToNode(block, pmSchema),
31-
);
29+
const nodesToInsert = blocksToInsert.map((block) => {
30+
const node = blockToNode(block, pmSchema);
31+
node.check(); // `blockToNode` is lenient; validate before mutating the doc
32+
return node;
33+
});
3234

3335
const posInfo = getNodeById(id, tr.doc);
3436
if (!posInfo) {

packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ export function removeAndInsertBlocks<
2727
const pmSchema = getPmSchema(tr);
2828
// Converts the `PartialBlock`s to ProseMirror nodes to insert them into the
2929
// document.
30-
const nodesToInsert: Node[] = blocksToInsert.map((block) =>
31-
blockToNode(block, pmSchema),
32-
);
30+
const nodesToInsert: Node[] = blocksToInsert.map((block) => {
31+
const node = blockToNode(block, pmSchema);
32+
node.check(); // `blockToNode` is lenient; validate before mutating the doc
33+
return node;
34+
});
3335

3436
const idsOfBlocksToRemove = new Set<string>(
3537
blocksToRemove.map((block) =>

packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,18 @@ export function updateBlockTr<
128128
// for this, we do a nodeToBlock on the existing block to get the children.
129129
// it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case
130130
const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema);
131+
const replacementNode = blockToNode(
132+
{
133+
children: existingBlock.children, // if no children are passed in, use existing children
134+
...block,
135+
},
136+
pmSchema,
137+
);
138+
replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc
131139
tr.replaceWith(
132140
blockInfo.bnBlock.beforePos,
133141
blockInfo.bnBlock.afterPos,
134-
blockToNode(
135-
{
136-
children: existingBlock.children, // if no children are passed in, use existing children
137-
...block,
138-
},
139-
pmSchema,
140-
),
142+
replacementNode,
141143
);
142144

143145
return;
@@ -278,7 +280,9 @@ function updateChildren<
278280
const pmSchema = getPmSchema(tr);
279281
if (block.children !== undefined && block.children.length > 0) {
280282
const childNodes = block.children.map((child) => {
281-
return blockToNode(child, pmSchema);
283+
const node = blockToNode(child, pmSchema);
284+
node.check(); // `blockToNode` is lenient; validate before mutating the doc
285+
return node;
282286
});
283287

284288
// Checks if a blockGroup node already exists.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,9 @@ export function blockToNode(
366366
groupNode ? [contentNode, groupNode] : contentNode,
367367
);
368368
} else if (schema.nodes[block.type].isInGroup("bnBlock")) {
369-
// this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node
370-
return schema.nodes[block.type].createChecked(
369+
// `create` (not `createChecked`) so partial container blocks pass through;
370+
// callers that mutate the doc validate via `node.check()` before inserting.
371+
return schema.nodes[block.type].create(
371372
{
372373
id: id,
373374
...block.props,

packages/xl-ai/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"@ai-sdk/mistral": "^3.0.2",
100100
"@ai-sdk/openai": "^3.0.2",
101101
"@ai-sdk/openai-compatible": "^2.0.2",
102+
"@blocknote/xl-multi-column": "0.50.0",
102103
"@mswjs/interceptors": "^0.37.6",
103104
"@types/diff": "^6.0.0",
104105
"@types/json-diff": "^1.0.3",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// @vitest-environment jsdom
2+
3+
import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
4+
import { withMultiColumn } from "@blocknote/xl-multi-column";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
7+
import { htmlBlockLLMFormat } from "./htmlBlocks.js";
8+
9+
const schema = withMultiColumn(BlockNoteSchema.create());
10+
11+
// Regression test for https://github.com/TypeCellOS/BlockNote/issues/2716
12+
describe("htmlBlockLLMFormat.defaultDocumentStateBuilder with multi-column", () => {
13+
let editor: BlockNoteEditor<any, any, any>;
14+
const div = document.createElement("div");
15+
16+
beforeEach(() => {
17+
editor = BlockNoteEditor.create({
18+
schema,
19+
initialContent: [
20+
{
21+
id: "column-list-0",
22+
type: "columnList",
23+
children: [
24+
{
25+
id: "column-0",
26+
type: "column",
27+
children: [
28+
{
29+
id: "left-paragraph",
30+
type: "paragraph",
31+
content: "left column",
32+
},
33+
],
34+
},
35+
{
36+
id: "column-1",
37+
type: "column",
38+
children: [
39+
{
40+
id: "right-paragraph",
41+
type: "paragraph",
42+
content: "right column",
43+
},
44+
],
45+
},
46+
],
47+
},
48+
],
49+
});
50+
editor.mount(div);
51+
});
52+
53+
afterEach(() => {
54+
editor._tiptapEditor.destroy();
55+
editor = undefined as any;
56+
});
57+
58+
it("builds the document state for a doc containing a columnList without throwing", async () => {
59+
const result = await htmlBlockLLMFormat.defaultDocumentStateBuilder({
60+
editor,
61+
streamTools: [],
62+
onStart: () => {
63+
// no-op
64+
},
65+
});
66+
67+
if (result.selection !== false) {
68+
throw new Error("expected selection-less document state");
69+
}
70+
71+
const idEntries = result.blocks.filter(
72+
(b): b is { id: string; block: string } => "id" in b,
73+
);
74+
75+
const columnListEntry = idEntries.find((b) =>
76+
b.id.includes("column-list-0"),
77+
);
78+
expect(columnListEntry).toBeDefined();
79+
expect(columnListEntry!.block).toContain('data-node-type="columnList"');
80+
81+
expect(idEntries.find((b) => b.id.includes("column-0"))).toBeDefined();
82+
expect(idEntries.find((b) => b.id.includes("column-1"))).toBeDefined();
83+
expect(
84+
idEntries.find((b) => b.id.includes("left-paragraph")),
85+
).toBeDefined();
86+
expect(
87+
idEntries.find((b) => b.id.includes("right-paragraph")),
88+
).toBeDefined();
89+
});
90+
91+
it("builds the document state when invoked with selectedBlocks containing a columnList", async () => {
92+
const result = await htmlBlockLLMFormat.defaultDocumentStateBuilder({
93+
editor,
94+
selectedBlocks: editor.document,
95+
streamTools: [],
96+
onStart: () => {
97+
// no-op
98+
},
99+
});
100+
101+
if (result.selection !== true) {
102+
throw new Error("expected selection-based document state");
103+
}
104+
expect(result.selectedBlocks.length).toBeGreaterThan(0);
105+
});
106+
});

packages/xl-ai/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export default defineConfig((conf) => ({
2626
"@blocknote/core": path.resolve(__dirname, "../core/src/"),
2727
"@blocknote/mantine": path.resolve(__dirname, "../mantine/src/"),
2828
"@blocknote/react": path.resolve(__dirname, "../react/src/"),
29+
"@blocknote/xl-multi-column": path.resolve(
30+
__dirname,
31+
"../xl-multi-column/src/",
32+
),
2933
"@shared": path.resolve(__dirname, "../../shared/"),
3034
} as Record<string, string>),
3135
},

pnpm-lock.yaml

Lines changed: 11 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)