@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
217 lines (165 loc) • 7.04 kB
text/typescript
import { afterEach, describe, expect, it } from "vite-plus/test";
import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { BlockNoteEditor } from "../../index.js";
import { ForkYDocExtension } from "./ForkYDoc.js";
import { withCollaboration } from "./index.js";
/**
* @vitest-environment jsdom
*/
function createCollabEditor() {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
const editor = BlockNoteEditor.create(
withCollaboration({
collaboration: {
fragment,
user: { name: "Test User", color: "#FF0000" },
provider: { awareness: new Awareness(doc) },
},
}),
);
const div = document.createElement("div");
editor.mount(div);
return { editor, doc, fragment };
}
function getEditorText(editor: BlockNoteEditor) {
return editor.prosemirrorState.doc.textContent;
}
function setEditorText(editor: BlockNoteEditor, text: string) {
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: [{ text, styles: {}, type: "text" }],
},
]);
}
let ctx: ReturnType<typeof createCollabEditor>;
afterEach(() => {
ctx?.editor.unmount();
ctx?.doc.destroy();
});
describe("ForkYDocExtension", () => {
it("forks the document — edits do not affect the original fragment", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Original");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
// Edit while forked
setEditorText(ctx.editor, "Forked edit");
// The original fragment should still have the original content
expect(ctx.fragment.toJSON()).toContain("Original");
expect(getEditorText(ctx.editor)).toBe("Forked edit");
});
it("merge({ keepChanges: false }) discards forked edits", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Original");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
setEditorText(ctx.editor, "Forked edit");
forkYDoc.merge({ keepChanges: false });
expect(getEditorText(ctx.editor)).toBe("Original");
});
it("merge({ keepChanges: true }) applies forked edits to the original doc", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Original");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
setEditorText(ctx.editor, "Forked edit");
forkYDoc.merge({ keepChanges: true });
// The editor and original fragment should both reflect the forked edit
expect(getEditorText(ctx.editor)).toContain("Forked edit");
});
it("fork({ initialUpdate }) uses the provided update instead of the live doc", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Current content");
// Create a snapshot of an earlier state
const snapshotDoc = new Y.Doc();
// Manually build content in the snapshot doc
Y.applyUpdate(snapshotDoc, Y.encodeStateAsUpdate(ctx.doc));
// Now modify the live editor
setEditorText(ctx.editor, "Modified after snapshot");
// Fork with the snapshot (which has "Current content", not "Modified after snapshot")
const snapshotUpdate = Y.encodeStateAsUpdate(snapshotDoc);
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork({ initialUpdate: snapshotUpdate });
// The editor should show the snapshot content, not the current live content
expect(getEditorText(ctx.editor)).toBe("Current content");
// The original fragment should still have the modified content
expect(ctx.fragment.toJSON()).toContain("Modified after snapshot");
});
it("fork({ initialUpdate }) + merge({ keepChanges: false }) restores live doc", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Live content");
// Create a snapshot update
const snapshotDoc = new Y.Doc();
Y.applyUpdate(snapshotDoc, Y.encodeStateAsUpdate(ctx.doc));
setEditorText(ctx.editor, "Updated live content");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork({ initialUpdate: Y.encodeStateAsUpdate(snapshotDoc) });
// Editor shows snapshot
expect(getEditorText(ctx.editor)).toBe("Live content");
// Merge without keeping changes
forkYDoc.merge({ keepChanges: false });
// Should be back to the live doc
expect(getEditorText(ctx.editor)).toBe("Updated live content");
});
it("calling fork() while already forked is a no-op", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Original");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
setEditorText(ctx.editor, "Forked edit");
// Second fork should be a no-op
forkYDoc.fork();
expect(getEditorText(ctx.editor)).toBe("Forked edit");
});
it("isForked store state reflects fork/merge lifecycle", () => {
ctx = createCollabEditor();
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
expect(forkYDoc.store.state.isForked).toBe(false);
forkYDoc.fork();
expect(forkYDoc.store.state.isForked).toBe(true);
forkYDoc.merge({ keepChanges: false });
expect(forkYDoc.store.state.isForked).toBe(false);
});
it("merge() is a no-op when not forked", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Untouched");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
// Should not throw or change anything.
forkYDoc.merge({ keepChanges: false });
forkYDoc.merge({ keepChanges: true });
expect(getEditorText(ctx.editor)).toBe("Untouched");
expect(forkYDoc.store.state.isForked).toBe(false);
});
it("forked doc is isolated from the original Y.Doc", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Before fork");
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork();
// Edit while forked
setEditorText(ctx.editor, "Forked edit");
// The original fragment should still have "Before fork"
expect(ctx.fragment.toJSON()).toContain("Before fork");
expect(ctx.fragment.toJSON()).not.toContain("Forked edit");
});
it("fork({ initialUpdate }) + merge({ keepChanges: true }) applies forked edits to original", () => {
ctx = createCollabEditor();
setEditorText(ctx.editor, "Current content");
// Take a snapshot
const snapshotDoc = new Y.Doc();
Y.applyUpdate(snapshotDoc, Y.encodeStateAsUpdate(ctx.doc));
// Move the live doc forward
setEditorText(ctx.editor, "Live content");
// Fork from the snapshot
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
forkYDoc.fork({ initialUpdate: Y.encodeStateAsUpdate(snapshotDoc) });
expect(getEditorText(ctx.editor)).toBe("Current content");
// Edit while forked
setEditorText(ctx.editor, "Forked modification");
// Merge and keep changes
forkYDoc.merge({ keepChanges: true });
expect(getEditorText(ctx.editor)).toContain("Forked modification");
});
});