UNPKG

@blocknote/core

Version:

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

257 lines (209 loc) 8.87 kB
import { Schema } from "prosemirror-model"; import { describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { docToBlocks } from "./nodeConversions/nodeToBlock.js"; import { getNodeId } from "./getBlockInfoFromPos.js"; import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js"; /** * Builds a `blockContainer` node holding a single paragraph with the given * block `id`. When `suggestedDelete` is true, the container carries a * `y-attributed-delete` mark, simulating a node that Yjs keeps in the document * (in suggestion mode) after it has been deleted. */ function makeBlockContainer( schema: Schema, id: string, text: string, suggestedDelete: boolean, ) { const paragraph = schema.nodes["paragraph"].createChecked( {}, text ? schema.text(text) : null, ); const marks = suggestedDelete ? [schema.marks["y-attributed-delete"].create({ id: 1 })] : undefined; return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); } describe("getNodeId", () => { let editor: BlockNoteEditor; // We only need the editor's ProseMirror schema to construct nodes, so a // single non-mounted editor instance is enough for all cases here. function getSchema() { if (!editor) { editor = BlockNoteEditor.create({ extensions: [YAttributionMarksExtension()], }); } return editor.pmSchema; } it("returns the plain id for a normal block", () => { const schema = getSchema(); const block = makeBlockContainer(schema, "0", "Hello", false); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, block), ); // The only descendant blockContainer with id "0" is the one we built. const blockContainer = doc.firstChild!.firstChild!; expect(getNodeId(blockContainer, doc)).toBe("0"); }); it("throws when a node has no id", () => { const schema = getSchema(); // `create` (not `createChecked`) so we can omit the id attr default lying. const block = schema.nodes["blockContainer"].create( { id: null }, schema.nodes["paragraph"].createChecked({}, schema.text("No id")), ); expect(() => getNodeId(block, block)).toThrow(/does not have an ID/); }); it("lies about the id of a suggested-deletion block to disambiguate duplicates", () => { const schema = getSchema(); // First block: a "real" block with id "0". const liveBlock = makeBlockContainer(schema, "0", "Live", false); // Second block: a suggested deletion that, in suggestion mode, shares the // SAME id "0" as the live block but carries a y-attributed-delete mark. const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]), ); const blockGroup = doc.firstChild!; const liveNode = blockGroup.child(0); const deletedNode = blockGroup.child(1); // The live block keeps its plain id. expect(getNodeId(liveNode, doc)).toBe("0"); // The suggested-deletion block is disambiguated: it is preceded by one // node with the same id, so its index is 1 -> "0-1". expect(getNodeId(deletedNode, doc)).toBe("0-1"); }); it("disambiguates multiple suggested-deletion blocks with the same id", () => { const schema = getSchema(); // Three blocks all sharing id "0": one live block followed by two // suggested deletions (e.g. the user deleted the same logical block twice // across forks, all kept in the doc with the y-attributed-delete mark). const liveBlock = makeBlockContainer(schema, "0", "Live", false); const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true); const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [ liveBlock, deletedBlock1, deletedBlock2, ]), ); const blockGroup = doc.firstChild!; expect(getNodeId(blockGroup.child(0), doc)).toBe("0"); // Preceded by 1 node with the same id. expect(getNodeId(blockGroup.child(1), doc)).toBe("0-1"); // Preceded by 2 nodes with the same id. expect(getNodeId(blockGroup.child(2), doc)).toBe("0-2"); }); it("counts only preceding same-id nodes, not unrelated blocks", () => { const schema = getSchema(); // A block with a different id sits between the live and deleted blocks. // It must NOT contribute to the suggested-deletion block's index. const liveBlock = makeBlockContainer(schema, "0", "Live", false); const otherBlock = makeBlockContainer(schema, "1", "Other", false); const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [ liveBlock, otherBlock, deletedBlock, ]), ); const blockGroup = doc.firstChild!; expect(getNodeId(blockGroup.child(0), doc)).toBe("0"); expect(getNodeId(blockGroup.child(1), doc)).toBe("1"); // Only the single live block with id "0" precedes it -> index 1. expect(getNodeId(blockGroup.child(2), doc)).toBe("0-1"); }); it("throws when a suggested-deletion node is not found in the provided doc", () => { const schema = getSchema(); // A suggested-deletion block that is NOT part of `doc` -> the walk never // finds it, so getNodeId throws. const orphanDeleted = makeBlockContainer(schema, "0", "Orphan", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked( {}, makeBlockContainer(schema, "0", "Live", false), ), ); expect(() => getNodeId(orphanDeleted, doc)).toThrow( /not found in document/, ); }); }); describe("docToBlocks round trip with suggested deletions", () => { let editor: BlockNoteEditor; function getSchema() { if (!editor) { editor = BlockNoteEditor.create({ extensions: [YAttributionMarksExtension()], }); } return editor.pmSchema; } it("reports distinct block ids even though two ProseMirror nodes share the same id", () => { const schema = getSchema(); // A live block and a suggested-deletion block that, in suggestion mode, // share the SAME ProseMirror id "0". const liveBlock = makeBlockContainer(schema, "0", "Live", false); const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]), ); // At the ProseMirror level, both nodes share id "0". const blockGroup = doc.firstChild!; expect(blockGroup.child(0).attrs.id).toBe("0"); expect(blockGroup.child(1).attrs.id).toBe("0"); // docToBlocks disambiguates them via getNodeId: the live block keeps "0", // the suggested-deletion block becomes "0-1". const blocks = docToBlocks(doc); const ids = blocks.map((block) => block.id); expect(ids).toEqual(["0", "0-1"]); // All block ids are distinct. expect(new Set(ids).size).toBe(ids.length); }); it("disambiguates multiple suggested-deletion blocks sharing an id in docToBlocks", () => { const schema = getSchema(); const liveBlock = makeBlockContainer(schema, "0", "Live", false); const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true); const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [ liveBlock, deletedBlock1, deletedBlock2, ]), ); const blocks = docToBlocks(doc); const ids = blocks.map((block) => block.id); expect(ids).toEqual(["0", "0-1", "0-2"]); expect(new Set(ids).size).toBe(ids.length); }); it("only disambiguates the suggested-deletion block, leaving unrelated ids intact", () => { const schema = getSchema(); const liveBlock = makeBlockContainer(schema, "0", "Live", false); const otherBlock = makeBlockContainer(schema, "1", "Other", false); const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); const doc = schema.nodes["doc"].createChecked( {}, schema.nodes["blockGroup"].createChecked({}, [ liveBlock, otherBlock, deletedBlock, ]), ); const blocks = docToBlocks(doc); const ids = blocks.map((block) => block.id); expect(ids).toEqual(["0", "1", "0-1"]); expect(new Set(ids).size).toBe(ids.length); }); });