UNPKG

@blocknote/core

Version:

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

419 lines (364 loc) 12.7 kB
/** * @vitest-environment jsdom */ import { describe, expect, it } from "vite-plus/test"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { trackPosition } from "../../api/positionMapping.js"; import { withCollaboration } from "./index.js"; // Function to sync two documents function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) { const update = Y.encodeStateAsUpdate(sourceDoc); Y.applyUpdate(targetDoc, update); } // Set up two-way sync function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) { syncDocs(doc1, doc2); syncDocs(doc2, doc1); doc1.on("update", (update: Uint8Array) => { Y.applyUpdate(doc2, update); }); doc2.on("update", (update: Uint8Array) => { Y.applyUpdate(doc1, update); }); } describe("RelativePositionMapping (yjs)", () => { it("should return the same position when no changes are made", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); const nodeSize = localEditor.prosemirrorState.doc.nodeSize; const positions: number[] = []; for (let i = 0; i < nodeSize; i++) { positions.push(trackPosition(localEditor, i)()); } expect(positions).toMatchInlineSnapshot(` [ 0, 1, 2, 3, 4, 5, 6, 7, ] `); ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); it("should update the local position when collaborating", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); localEditor.replaceBlocks(localEditor.document, [ { type: "paragraph", content: "Hello World", }, ]); // Store position at "Hello| World" const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) expect(getStartPos()).toBe(3); // 3 expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); it("should match the same positions", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); localEditor.replaceBlocks(localEditor.document, [ { type: "paragraph", content: "Hello World", }, ]); const nodeSize = localEditor.prosemirrorState.doc.nodeSize; const positions: (() => number)[] = []; for (let i = 0; i < nodeSize; i++) { positions.push(trackPosition(localEditor, i)); } localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); expect(positions.map((getPos) => getPos())).toMatchInlineSnapshot(` [ 0, 1, 2, 3, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, ] `); ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); it("should handle multiple transactions when collaborating", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); localEditor.replaceBlocks(localEditor.document, [ { type: "paragraph", content: "Hello World", }, ]); // Store position at "Hello| World" const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "T"); localEditor._tiptapEditor.commands.insertContentAt(4, "e"); localEditor._tiptapEditor.commands.insertContentAt(5, "s"); localEditor._tiptapEditor.commands.insertContentAt(6, "t"); localEditor._tiptapEditor.commands.insertContentAt(7, " "); // Position should be updated expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) expect(getStartPos()).toBe(3); // 3 expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); it("should update the local position from a remote transaction", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); remoteEditor.replaceBlocks(remoteEditor.document, [ { type: "paragraph", content: "Hello World", }, ]); // Store position at "Hello| World" const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning (via remote editor to exercise remote-origin updates) remoteEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) expect(getStartPos()).toBe(3); // 3 expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); it("should update the remote position from a remote transaction", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); const localEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: ydoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Local User" }, provider: undefined, }, }), ); const div = document.createElement("div"); localEditor.mount(div); const remoteEditor = BlockNoteEditor.create( withCollaboration({ collaboration: { fragment: remoteYdoc.getXmlFragment("doc"), user: { color: "#ff0000", name: "Remote User" }, provider: undefined, }, }), ); const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); setupTwoWaySync(ydoc, remoteYdoc); remoteEditor.replaceBlocks(remoteEditor.document, [ { type: "paragraph", content: "Hello World", }, ]); // Store position at "Hello| World" const getCursorPos = trackPosition(remoteEditor, 6); // Store position at "|Hello World" const getStartPos = trackPosition(remoteEditor, 3); // Store position at "|Hello World" (but on the right side) const getStartRightPos = trackPosition(remoteEditor, 3, "right"); // Store position at "H|ello World" const getPosAfterPos = trackPosition(remoteEditor, 4); // Store position at "H|ello World" (but on the right side) const getPosAfterRightPos = trackPosition(remoteEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) expect(getStartPos()).toBe(3); // 3 expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) ydoc.destroy(); remoteYdoc.destroy(); localEditor.unmount(); remoteEditor.unmount(); }); });