UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

979 lines (870 loc) 27.5 kB
import { describe, expect, it } from "vite-plus/test"; import type { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { getBlockInfo } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { updateBlock } from "./updateBlock.js"; const getEditor = setupTestEnv(); describe("Test updateBlock typing", () => { it("Type is inferred correctly", () => { try { getEditor().updateBlock( { id: "placeholder-id" }, { // @ts-expect-error invalid type type: "non-existing", }, ); } catch { // ID doesn't exist, which is fine - this is a compile-time check } }); it("Props are inferred correctly", () => { try { getEditor().updateBlock( { id: "placeholder-id" }, { type: "paragraph", props: { // @ts-expect-error invalid type level: 1, }, }, ); } catch { // ID doesn't exist, which is fine - this is a compile-time check } try { getEditor().updateBlock( { id: "placeholder-id" }, { type: "heading", props: { level: 1, }, }, ); } catch { // ID doesn't exist, which is fine - this is a compile-time check } }); }); describe("Test updateBlock", () => { it.skip("Update ID", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { id: "new-id", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update type", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { type: "paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update single prop", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { props: { level: 3, }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update all props", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { props: { backgroundColor: "blue", level: 3, textAlignment: "right", textColor: "blue", }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Revert single prop", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { props: { level: undefined, }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Revert all props", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { props: { backgroundColor: undefined, level: undefined, textAlignment: undefined, textColor: undefined, }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update with plain content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { content: "New content", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update with styled content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { content: [ { type: "text", text: "New", styles: { backgroundColor: "blue" }, }, { type: "text", text: " ", styles: {} }, { type: "text", text: "content", styles: { backgroundColor: "blue" }, }, ], }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update partial (offset start)", () => { const info = getBlockInfo( getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); if (!info.isBlockContainer) { throw new Error("heading-with-everything is not a block container"); } getEditor().transact((tr) => updateBlock( tr, "heading-with-everything", { content: [ { type: "text", text: "without styles", styles: {}, }, ], }, info.blockContent.beforePos + 9, ), ); expect(getEditor().document).toMatchSnapshot(); }); it("Update partial (offset start + end)", () => { const info = getBlockInfo( getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); if (!info.isBlockContainer) { throw new Error("heading-with-everything is not a block container"); } getEditor().transact((tr) => updateBlock( tr, "heading-with-everything", { content: [ { type: "text", text: "without styles and ", styles: {}, }, ], }, info.blockContent.beforePos + 9, info.blockContent.beforePos + 9, ), ); expect(getEditor().document).toMatchSnapshot(); }); it("Update partial (props + offset end)", () => { const info = getBlockInfo( getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); if (!info.isBlockContainer) { throw new Error("heading-with-everything is not a block container"); } getEditor().transact((tr) => { updateBlock( tr, "heading-with-everything", { props: { level: 1, }, content: [ { type: "text", text: "Title", styles: {}, }, ], }, undefined, info.blockContent.beforePos + 8, ); }); expect(getEditor().document).toMatchSnapshot(); }); it("Update partial (table cell)", () => { const info = getBlockInfo( getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); if (!info.isBlockContainer) { throw new Error("table-0 is not a block container"); } const cell = info.blockContent.node.resolve(2); getEditor().transact((tr) => updateBlock( tr, "table-0", { type: "table", content: { type: "tableContent", rows: [{ cells: ["updated cell 1"] }], }, }, info.blockContent.beforePos + 2, info.blockContent.beforePos + 2 + cell.node().nodeSize, ), ); expect(getEditor().document).toMatchSnapshot(); }); it("Update partial (table row)", () => { const info = getBlockInfo( getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); if (!info.isBlockContainer) { throw new Error("table-0 is not a block container"); } const cell = info.blockContent.node.resolve(1); getEditor().transact((tr) => updateBlock( tr, "table-0", { type: "table", content: { type: "tableContent", rows: [ { cells: ["updated cell 1", "updated cell 2", "updated cell 3"], }, ], }, }, info.blockContent.beforePos + 1, info.blockContent.beforePos + 1 + cell.node().nodeSize, ), ); expect(getEditor().document).toMatchSnapshot(); }); it("Update children", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { children: [ { id: "new-nested-paragraph", type: "paragraph", content: "New nested Paragraph 2", children: [ { id: "new-double-nested-paragraph", type: "paragraph", content: "New double Nested Paragraph 2", }, ], }, ], }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it.skip("Update everything", () => { expect( getEditor().transact((tr) => updateBlock(tr, "heading-with-everything", { id: "new-id", type: "paragraph", props: { backgroundColor: "blue", textAlignment: "right", textColor: "blue", }, content: [ { type: "text", text: "New", styles: { backgroundColor: "blue" }, }, { type: "text", text: " ", styles: {} }, { type: "text", text: "content", styles: { backgroundColor: "blue" }, }, ], children: [ { id: "new-nested-paragraph", type: "paragraph", content: "New nested Paragraph 2", children: [ { id: "new-double-nested-paragraph", type: "paragraph", content: "New double Nested Paragraph 2", }, ], }, ], }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update inline content to empty table content", () => { expect(() => { getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "table", }), ); }).toThrow(); }); it("Update table content to empty inline content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "table-0", { type: "paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update inline content to table content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "table", content: { type: "tableContent", rows: [ { cells: ["Cell 1", "Cell 2", "Cell 3"], }, { cells: ["Cell 4", "Cell 5", "Cell 6"], }, { cells: ["Cell 7", "Cell 8", "Cell 9"], }, ], }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update table content to inline content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "table-0", { type: "paragraph", content: "Paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update inline content to no content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "image", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update inline content to plain content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "codeBlock", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update styled inline content to plain content", () => { // The plain block can't hold formatting marks, so the styling is dropped // but the text is preserved. expect( getEditor().transact((tr) => updateBlock(tr, "paragraph-with-styled-content", { type: "codeBlock", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update plain content to inline content", () => { getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "codeBlock" }), ); expect( getEditor().transact((tr) => updateBlock(tr, "paragraph-0", { type: "paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update no content to empty inline content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "image-0", { type: "paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update no content to inline content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "image-0", { type: "paragraph", content: "Paragraph", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update no content to empty table content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "image-0", { type: "table", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update no content to table content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "image-0", { type: "table", content: { type: "tableContent", rows: [ { cells: [ { type: "tableCell", content: ["Cell 1"], }, { type: "tableCell", content: ["Cell 2"], props: { backgroundColor: "red", colspan: 1, rowspan: 1, textAlignment: "right", textColor: "red", }, }, { type: "tableCell", content: ["Cell 3"], props: { backgroundColor: "default", colspan: 1, rowspan: 1, textAlignment: "left", textColor: "default", }, }, ], }, { cells: ["Cell 4", "Cell 5", "Cell 6"], }, { cells: ["Cell 7", "Cell 8", "Cell 9"], }, ], }, }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); it("Update table content to no content", () => { expect( getEditor().transact((tr) => updateBlock(tr, "table-0", { type: "image", }), ), ).toMatchSnapshot(); expect(getEditor().document).toMatchSnapshot(); }); }); /** * These tests assert that `updateBlock` produces the smallest possible set of * ProseMirror steps, rather than replacing whole blocks/content/children when * only a small part changed. */ describe("Test updateBlock minimal steps", () => { // Runs `updateBlock` in a throwaway transaction and returns the resulting // steps as JSON for inspection. const getSteps = (blockId: string, update: PartialBlock<any, any, any>) => { let steps: any[] = []; getEditor().transact((tr) => { updateBlock(tr, blockId, update); steps = tr.steps.map((s) => s.toJSON()); }); return steps; }; it("Changing a single prop emits only attr steps", () => { const steps = getSteps("heading-with-everything", { props: { level: 3 }, }); expect(steps).toEqual([ { stepType: "attr", pos: expect.any(Number), attr: "level", value: 3, }, ]); }); it("Changing only children does not touch content or container", () => { const steps = getSteps("heading-with-everything", { children: [ { id: "new-nested-paragraph", type: "paragraph", content: "New nested Paragraph 2", }, ], }); // A single replace step covering the children range only. expect(steps).toHaveLength(1); expect(steps[0].stepType).toBe("replace"); }); it("Appending a child is a pure insertion", () => { const steps = getSteps("heading-with-everything", { children: [ // Existing child, kept as-is. { id: "nested-paragraph-1", type: "paragraph", content: "Nested Paragraph 1", children: [ { id: "double-nested-paragraph-1", type: "paragraph", content: "Double Nested Paragraph 1", }, ], }, // New sibling. { id: "appended-child", type: "paragraph", content: "Appended", }, ], }); expect(steps).toHaveLength(1); expect(steps[0].stepType).toBe("replace"); // Pure insertion: from === to (nothing deleted). expect(steps[0].from).toBe(steps[0].to); expect( (getEditor().getBlock("heading-with-everything") as any).children.map( (c: any) => c.id, ), ).toEqual(["nested-paragraph-1", "appended-child"]); }); it("Changing part of the content only replaces the changed range", () => { // Original content: "Heading" + " with styled " + "content". // Only the middle text changes. const steps = getSteps("heading-with-everything", { content: [ { type: "text", text: "Heading", styles: { bold: true } }, { type: "text", text: " with NEW ", styles: {} }, { type: "text", text: "content", styles: { italic: true } }, ], }); expect(steps).toHaveLength(1); const [step] = steps; expect(step.stepType).toBe("replace"); // The replaced range should be the diff ("styled" -> "NEW"), much smaller // than the whole content node. expect(step.to - step.from).toBeLessThan( "Heading with styled content".length, ); // Only the changed text is inserted. expect(step.slice.content[0].text).toBe("NEW"); }); it("Changing one table cell only replaces that cell's text", () => { const steps = getSteps("table-0", { type: "table", content: { type: "tableContent", rows: [ { cells: ["Cell 1", "Cell 2", "Cell 3"] }, { cells: ["Cell 4", "CHANGED", "Cell 6"] }, { cells: ["Cell 7", "Cell 8", "Cell 9"] }, ], }, }); expect(steps).toHaveLength(1); expect(steps[0].stepType).toBe("replace"); const block = getEditor().getBlock("table-0") as any; expect(block.content.rows[1].cells[1].content[0].text).toBe("CHANGED"); // Surrounding cells are untouched. expect(block.content.rows[0].cells[0].content[0].text).toBe("Cell 1"); expect(block.content.rows[2].cells[2].content[0].text).toBe("Cell 9"); }); it("Updating with identical content/props/children emits no steps", () => { const steps = getSteps("heading-with-everything", { type: "heading", props: { backgroundColor: "red", level: 2, textAlignment: "center", textColor: "red", }, content: [ { type: "text", text: "Heading", styles: { bold: true } }, { type: "text", text: " with styled ", styles: {} }, { type: "text", text: "content", styles: { italic: true } }, ], }); expect(steps).toEqual([]); }); it("Changing only a child's content replaces just that child's text", () => { const steps = getSteps("paragraph-with-children", { children: [ { id: "nested-paragraph-0", type: "paragraph", content: "CHANGED nested", children: [ { id: "double-nested-paragraph-0", type: "paragraph", content: "Double Nested Paragraph 0", }, ], }, ], }); // Diffed down to a single replace; the unchanged double-nested child and // surrounding structure are left in place. expect(steps).toHaveLength(1); expect(steps[0].stepType).toBe("replace"); const block = getEditor().getBlock("paragraph-with-children") as any; expect(block.children[0].content[0].text).toBe("CHANGED nested"); expect(block.children[0].children[0].id).toBe("double-nested-paragraph-0"); expect(block.children[0].children[0].content[0].text).toBe( "Double Nested Paragraph 0", ); }); it("Removing a table row only replaces the removed range", () => { const steps = getSteps("table-0", { type: "table", content: { type: "tableContent", rows: [ { cells: ["Cell 1", "Cell 2", "Cell 3"] }, { cells: ["Cell 7", "Cell 8", "Cell 9"] }, ], }, }); // Snapped to the removed (middle) row: a single replace where content is // deleted (slice is empty -> from < to). expect(steps).toHaveLength(1); expect(steps[0].stepType).toBe("replace"); expect(steps[0].to).toBeGreaterThan(steps[0].from); const block = getEditor().getBlock("table-0") as any; expect(block.content.rows).toHaveLength(2); expect(block.content.rows[0].cells[0].content[0].text).toBe("Cell 1"); expect(block.content.rows[1].cells[2].content[0].text).toBe("Cell 9"); }); // Regression: when the content node's TYPE changes, setNodeMarkupMinimal // emits a ReplaceAroundStep. Mapping the original beforePos through that step // with the default (forward) bias pushes the position past the node, so the // subsequent content diff resolved into the wrong place and corrupted the doc. // The position must be mapped with a -1 (before) bias. it("Type change + content change keeps the document valid", () => { const steps = getSteps("paragraph-3", { type: "heading", props: { level: 1 }, content: "Now a heading", }); // The type change requires a ReplaceAroundStep, but no error should be // thrown and the resulting block must be correct. expect(steps.length).toBeGreaterThan(0); const block = getEditor().getBlock("paragraph-3") as any; expect(block.type).toBe("heading"); expect(block.props.level).toBe(1); expect(block.content[0].text).toBe("Now a heading"); // The editor document must still be internally consistent. expect(() => getEditor()._tiptapEditor.state.doc.check()).not.toThrow(); }); it("Type change followed by another update resolves positions correctly", () => { const editor = getEditor(); // First: change the content-node type (forces a ReplaceAroundStep), which // is the operation that previously left later positions stale. editor.transact((tr) => { updateBlock(tr, "paragraph-4", { type: "heading", props: { level: 2 }, content: "Heading four", }); }); // Then: a follow-up update on a *later* block, whose position would be wrong // if the first transaction had corrupted the doc length. expect(() => { editor.transact((tr) => { updateBlock(tr, "paragraph-9", { content: "Paragraph nine updated", }); }); }).not.toThrow(); expect((editor.getBlock("paragraph-4") as any).type).toBe("heading"); expect((editor.getBlock("paragraph-9") as any).content[0].text).toBe( "Paragraph nine updated", ); expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); }); // Strongest regression: a children change (adds a step BEFORE the content // node is updated) combined with a content-node type change (ReplaceAroundStep) // and a content change. This is the exact mix that left the original beforePos // stale; without the -1 mapping bias the content diff resolves into the wrong // place and corrupts/throws. it("Children change + type change + content change stays valid", () => { const editor = getEditor(); expect(() => { editor.transact((tr) => { updateBlock(tr, "paragraph-with-children", { type: "heading", props: { level: 2 }, content: "Converted to heading", children: [ { id: "nested-paragraph-0", type: "paragraph", content: "Updated nested", }, { id: "brand-new-child", type: "paragraph", content: "Brand new child", }, ], }); }); }).not.toThrow(); const block = editor.getBlock("paragraph-with-children") as any; expect(block.type).toBe("heading"); expect(block.props.level).toBe(2); expect(block.content[0].text).toBe("Converted to heading"); expect(block.children.map((c: any) => c.id)).toEqual([ "nested-paragraph-0", "brand-new-child", ]); expect(block.children[0].content[0].text).toBe("Updated nested"); expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); // A follow-up update on a later block must still resolve correctly. expect(() => { editor.transact((tr) => { updateBlock(tr, "paragraph-9", { content: "After conversion" }); }); }).not.toThrow(); expect((editor.getBlock("paragraph-9") as any).content[0].text).toBe( "After conversion", ); }); it("Type change with offset content replace stays minimal and valid", () => { const editor = getEditor(); const info = getBlockInfo( getNodeById( "paragraph-with-styled-content", editor.prosemirrorState.doc, )!, ); if (!info.isBlockContainer) { throw new Error("paragraph-with-styled-content is not a block container"); } // paragraph-with-styled-content is "Paragraph"(bold) + " with styled " + // "content"(italic). Replace only the unstyled middle node while converting // the block to a heading. The type change forces a ReplaceAroundStep, after // which the offset-replace branch must still resolve the (now shifted) // content position correctly. let steps: any[] = []; editor.transact((tr) => { updateBlock( tr, "paragraph-with-styled-content", { type: "heading", props: { level: 3 }, content: [{ type: "text", text: " with NEW ", styles: {} }], }, info.blockContent.beforePos + 1 + "Paragraph".length, info.blockContent.beforePos + 1 + "Paragraph with styled ".length, ); steps = tr.steps.map((s) => s.toJSON()); }); expect(steps.length).toBeGreaterThan(0); const block = editor.getBlock("paragraph-with-styled-content") as any; expect(block.type).toBe("heading"); expect(block.props.level).toBe(3); // The styled text on either side of the replaced range is preserved. expect(block.content[0].text).toBe("Paragraph"); expect(block.content[block.content.length - 1].text).toBe("content"); expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); }); });