diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 81390947bf..25debee60c 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -26,9 +26,11 @@ export function insertBlocks< const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; const pmSchema = getPmSchema(tr); - const nodesToInsert = blocksToInsert.map((block) => - blockToNode(block, pmSchema), - ); + const nodesToInsert = blocksToInsert.map((block) => { + const node = blockToNode(block, pmSchema); + node.check(); // `blockToNode` is lenient; validate before mutating the doc + return node; + }); const posInfo = getNodeById(id, tr.doc); if (!posInfo) { diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index 04a2425a33..f1e946f909 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -27,9 +27,11 @@ export function removeAndInsertBlocks< const pmSchema = getPmSchema(tr); // Converts the `PartialBlock`s to ProseMirror nodes to insert them into the // document. - const nodesToInsert: Node[] = blocksToInsert.map((block) => - blockToNode(block, pmSchema), - ); + const nodesToInsert: Node[] = blocksToInsert.map((block) => { + const node = blockToNode(block, pmSchema); + node.check(); // `blockToNode` is lenient; validate before mutating the doc + return node; + }); const idsOfBlocksToRemove = new Set( blocksToRemove.map((block) => diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 4318b19ca7..a3e2b3b0db 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -128,16 +128,18 @@ export function updateBlockTr< // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema); + const replacementNode = blockToNode( + { + children: existingBlock.children, // if no children are passed in, use existing children + ...block, + }, + pmSchema, + ); + replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc tr.replaceWith( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, - blockToNode( - { - children: existingBlock.children, // if no children are passed in, use existing children - ...block, - }, - pmSchema, - ), + replacementNode, ); return; @@ -278,7 +280,9 @@ function updateChildren< const pmSchema = getPmSchema(tr); if (block.children !== undefined && block.children.length > 0) { const childNodes = block.children.map((child) => { - return blockToNode(child, pmSchema); + const node = blockToNode(child, pmSchema); + node.check(); // `blockToNode` is lenient; validate before mutating the doc + return node; }); // Checks if a blockGroup node already exists. diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 206ff8d9fd..d970227a49 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -366,8 +366,9 @@ export function blockToNode( groupNode ? [contentNode, groupNode] : contentNode, ); } else if (schema.nodes[block.type].isInGroup("bnBlock")) { - // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node - return schema.nodes[block.type].createChecked( + // `create` (not `createChecked`) so partial container blocks pass through; + // callers that mutate the doc validate via `node.check()` before inserting. + return schema.nodes[block.type].create( { id: id, ...block.props, diff --git a/packages/xl-ai/package.json b/packages/xl-ai/package.json index 804ddd207a..fce1acaf8b 100644 --- a/packages/xl-ai/package.json +++ b/packages/xl-ai/package.json @@ -99,6 +99,7 @@ "@ai-sdk/mistral": "^3.0.2", "@ai-sdk/openai": "^3.0.2", "@ai-sdk/openai-compatible": "^2.0.2", + "@blocknote/xl-multi-column": "0.50.0", "@mswjs/interceptors": "^0.37.6", "@types/diff": "^6.0.0", "@types/json-diff": "^1.0.3", diff --git a/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts b/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts new file mode 100644 index 0000000000..ef3ce0c034 --- /dev/null +++ b/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { withMultiColumn } from "@blocknote/xl-multi-column"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { htmlBlockLLMFormat } from "./htmlBlocks.js"; + +const schema = withMultiColumn(BlockNoteSchema.create()); + +// Regression test for https://github.com/TypeCellOS/BlockNote/issues/2716 +describe("htmlBlockLLMFormat.defaultDocumentStateBuilder with multi-column", () => { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeEach(() => { + editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { + id: "column-list-0", + type: "columnList", + children: [ + { + id: "column-0", + type: "column", + children: [ + { + id: "left-paragraph", + type: "paragraph", + content: "left column", + }, + ], + }, + { + id: "column-1", + type: "column", + children: [ + { + id: "right-paragraph", + type: "paragraph", + content: "right column", + }, + ], + }, + ], + }, + ], + }); + editor.mount(div); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + it("builds the document state for a doc containing a columnList without throwing", async () => { + const result = await htmlBlockLLMFormat.defaultDocumentStateBuilder({ + editor, + streamTools: [], + onStart: () => { + // no-op + }, + }); + + if (result.selection !== false) { + throw new Error("expected selection-less document state"); + } + + const idEntries = result.blocks.filter( + (b): b is { id: string; block: string } => "id" in b, + ); + + const columnListEntry = idEntries.find((b) => + b.id.includes("column-list-0"), + ); + expect(columnListEntry).toBeDefined(); + expect(columnListEntry!.block).toContain('data-node-type="columnList"'); + + expect(idEntries.find((b) => b.id.includes("column-0"))).toBeDefined(); + expect(idEntries.find((b) => b.id.includes("column-1"))).toBeDefined(); + expect( + idEntries.find((b) => b.id.includes("left-paragraph")), + ).toBeDefined(); + expect( + idEntries.find((b) => b.id.includes("right-paragraph")), + ).toBeDefined(); + }); + + it("builds the document state when invoked with selectedBlocks containing a columnList", async () => { + const result = await htmlBlockLLMFormat.defaultDocumentStateBuilder({ + editor, + selectedBlocks: editor.document, + streamTools: [], + onStart: () => { + // no-op + }, + }); + + if (result.selection !== true) { + throw new Error("expected selection-based document state"); + } + expect(result.selectedBlocks.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/xl-ai/vite.config.ts b/packages/xl-ai/vite.config.ts index a29ee235a8..2c56391772 100644 --- a/packages/xl-ai/vite.config.ts +++ b/packages/xl-ai/vite.config.ts @@ -26,6 +26,10 @@ export default defineConfig((conf) => ({ "@blocknote/core": path.resolve(__dirname, "../core/src/"), "@blocknote/mantine": path.resolve(__dirname, "../mantine/src/"), "@blocknote/react": path.resolve(__dirname, "../react/src/"), + "@blocknote/xl-multi-column": path.resolve( + __dirname, + "../xl-multi-column/src/", + ), "@shared": path.resolve(__dirname, "../../shared/"), } as Record), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94f6a67ab1..af14b567c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5061,6 +5061,9 @@ importers: '@ai-sdk/openai-compatible': specifier: ^2.0.2 version: 2.0.2(zod@4.3.6) + '@blocknote/xl-multi-column': + specifier: 0.50.0 + version: link:../xl-multi-column '@mswjs/interceptors': specifier: ^0.37.6 version: 0.37.6 @@ -23692,8 +23695,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -23742,7 +23745,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -23753,7 +23756,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -23767,14 +23770,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -23815,7 +23818,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -23826,7 +23829,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3