@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
396 lines (321 loc) • 13.1 kB
text/typescript
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it } from "vite-plus/test";
import * as Y from "@y/y";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { VersioningExtension } from "../../extensions/Versioning/index.js";
import type { VersioningEndpoints } from "../../extensions/Versioning/index.js";
import { withCollaboration } from "./index.js";
import { createYjsVersioningAdapter } from "./Versioning.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Simple in-memory Yjs versioning endpoints for tests.
* Stores snapshots and their binary content in plain Maps.
*/
function createInMemoryYjsEndpoints(): VersioningEndpoints<Y.Type, Uint8Array> {
const snapshots = new Map<
string,
{
id: string;
name?: string;
createdAt: number;
updatedAt: number;
restoredFromSnapshotId?: string;
}
>();
const contents = new Map<string, Uint8Array>();
return {
list: async () =>
[...snapshots.values()].sort((a, b) => b.createdAt - a.createdAt),
create: async (fragment, options) => {
const snapshot = {
id: crypto.randomUUID(),
name: options?.name,
createdAt: Date.now(),
updatedAt: Date.now(),
restoredFromSnapshotId: options?.restoredFromSnapshot?.id
? String(options.restoredFromSnapshot.id)
: undefined,
};
contents.set(snapshot.id, Y.encodeStateAsUpdateV2(fragment.doc!));
snapshots.set(snapshot.id, snapshot);
return snapshot;
},
getContent: async (snapshot) => {
const data = contents.get(String(snapshot.id));
if (!data) {
throw new Error(`Snapshot ${String(snapshot.id)} not found`);
}
return data;
},
restore: async (fragment, snapshot) => {
// Create backup
const backup = {
id: crypto.randomUUID(),
name: "Backup",
createdAt: Date.now(),
updatedAt: Date.now(),
};
contents.set(backup.id, Y.encodeStateAsUpdateV2(fragment.doc!));
snapshots.set(backup.id, backup);
const snapshotContent = contents.get(String(snapshot.id))!;
const tempDoc = new Y.Doc();
Y.applyUpdateV2(tempDoc, snapshotContent);
const restored = {
id: crypto.randomUUID(),
name: "Restored Snapshot",
createdAt: Date.now(),
updatedAt: Date.now(),
restoredFromSnapshotId: String(snapshot.id),
};
contents.set(restored.id, Y.encodeStateAsUpdateV2(tempDoc));
snapshots.set(restored.id, restored);
tempDoc.destroy();
return snapshotContent;
},
rename: async (snapshot, name) => {
const s = snapshots.get(String(snapshot.id));
if (!s) {
throw new Error(`Snapshot ${String(snapshot.id)} not found`);
}
s.name = name;
s.updatedAt = Date.now();
},
};
}
/** Create a collaborative editor with versioning, mounted to a jsdom div. */
function createCollabEditor(opts?: { withVersioning?: boolean }) {
const doc = new Y.Doc();
const fragment = doc.get("doc");
const endpoints = createInMemoryYjsEndpoints();
const editor = BlockNoteEditor.create(
withCollaboration({
collaboration: {
fragment,
user: { name: "Test User", color: "#ff0000" },
provider: undefined,
versioningEndpoints:
opts?.withVersioning !== false ? endpoints : undefined,
},
}),
);
const div = document.createElement("div");
editor.mount(div);
return { editor, doc, fragment, endpoints };
}
/** Clean up an editor and its Y.Doc. */
function cleanup(ctx: { editor: BlockNoteEditor<any, any, any>; doc: Y.Doc }) {
ctx.editor.unmount();
ctx.doc.destroy();
}
/** Get the editor's current ProseMirror doc text content. */
function getEditorText(editor: BlockNoteEditor<any, any, any>): string {
return editor.prosemirrorState.doc.textContent;
}
// ---------------------------------------------------------------------------
// Tests: createYjsVersioningAdapter (unit-level)
// ---------------------------------------------------------------------------
describe("createYjsVersioningAdapter", () => {
let ctx: ReturnType<typeof createCollabEditor>;
afterEach(() => {
if (ctx) {
cleanup(ctx);
}
});
it("getCurrentDocument returns the fragment passed to the adapter", () => {
ctx = createCollabEditor();
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
const state = adapter.getCurrentDocument();
expect(state).toBe(ctx.fragment);
expect(state.doc).toBe(ctx.doc);
});
it("enterPreview reconfigures the editor to show snapshot content", () => {
ctx = createCollabEditor();
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Original content" },
]);
const snapshotData = Y.encodeStateAsUpdateV2(ctx.doc);
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Modified content" },
]);
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
adapter.preview.enterPreview(snapshotData);
expect(getEditorText(ctx.editor)).toContain("Original content");
expect(getEditorText(ctx.editor)).not.toContain("Modified");
});
it("exitPreview resumes sync, showing the live document", () => {
ctx = createCollabEditor();
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Snapshot state" },
]);
const snapshotData = Y.encodeStateAsUpdateV2(ctx.doc);
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Current state" },
]);
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
adapter.preview.enterPreview(snapshotData);
expect(getEditorText(ctx.editor)).toContain("Snapshot state");
adapter.preview.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Current state");
});
it("successive enterPreview calls switch between snapshots", () => {
ctx = createCollabEditor();
// Create snapshot A
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Snapshot A" },
]);
const snapshotA = Y.encodeStateAsUpdateV2(ctx.doc);
// Create snapshot B
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Snapshot B" },
]);
const snapshotB = Y.encodeStateAsUpdateV2(ctx.doc);
// Move to current content
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Current" },
]);
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
// Preview A
adapter.preview.enterPreview(snapshotA);
expect(getEditorText(ctx.editor)).toContain("Snapshot A");
// Switch to B without exiting first
adapter.preview.enterPreview(snapshotB);
expect(getEditorText(ctx.editor)).toContain("Snapshot B");
// Exit should restore the live doc
adapter.preview.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Current");
});
it("exitPreview is a no-op when not previewing", () => {
ctx = createCollabEditor();
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Content" },
]);
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
// Should not throw or change anything
adapter.preview.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Content");
});
it("applyRestore is a no-op (server-side restore propagates via live sync)", () => {
ctx = createCollabEditor();
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Content" },
]);
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
// Should not throw and should leave the live document untouched.
expect(() => adapter.preview.applyRestore(new Uint8Array())).not.toThrow();
expect(getEditorText(ctx.editor)).toContain("Content");
});
});
// ---------------------------------------------------------------------------
// Tests: Full integration with VersioningExtension + localStorageEndpoints
// ---------------------------------------------------------------------------
describe("Yjs versioning integration (VersioningExtension + in-memory endpoints)", () => {
let ctx: ReturnType<typeof createCollabEditor>;
afterEach(() => {
if (ctx) {
cleanup(ctx);
}
});
it("previews a snapshot, showing the old content in the editor", async () => {
ctx = createCollabEditor();
const versioning = ctx.editor.getExtension(VersioningExtension)!;
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Snapshot content" },
]);
const snapshot = await versioning.create!({ name: "v1" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Current content" },
]);
await versioning.previewSnapshot(snapshot.id);
expect(versioning.store.state.previewedSnapshotId).toBe(snapshot.id);
expect(getEditorText(ctx.editor)).toContain("Snapshot content");
expect(getEditorText(ctx.editor)).not.toContain("Current");
});
it("exits preview and returns to live document", async () => {
ctx = createCollabEditor();
const versioning = ctx.editor.getExtension(VersioningExtension)!;
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Saved state" },
]);
const snapshot = await versioning.create!({ name: "v1" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Live state" },
]);
await versioning.previewSnapshot(snapshot.id);
versioning.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Live state");
});
it("full workflow: create, browse, preview, exit", async () => {
ctx = createCollabEditor();
const versioning = ctx.editor.getExtension(VersioningExtension)!;
// Create two versions
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Version 1" },
]);
const v1 = await versioning.create!({ name: "v1" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Version 2" },
]);
const v2 = await versioning.create!({ name: "v2" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Current state" },
]);
// List and verify ordering
const list = await versioning.list();
expect(list).toHaveLength(2);
expect(list[0]!.id).toBe(v2.id);
// Browse previews
await versioning.previewSnapshot(v1.id);
expect(getEditorText(ctx.editor)).toContain("Version 1");
await versioning.previewSnapshot(v2.id, { compareTo: v1.id });
expect(getEditorText(ctx.editor).length).toBeGreaterThan(0);
// Exit back to live
versioning.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Current state");
});
it("restore resolves with the restored snapshot content", async () => {
ctx = createCollabEditor();
const versioning = ctx.editor.getExtension(VersioningExtension)!;
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Content" },
]);
const snap = await versioning.create!({ name: "v1" });
// applyRestore is a no-op for the Yjs adapter (the backend applies the
// restore and the change propagates over live sync), so restore
// resolves with the snapshot content returned by the endpoint.
const content = await versioning.restore!(snap.id);
expect(content).toBeInstanceOf(Uint8Array);
});
it("previewing multiple snapshots and switching between them", async () => {
ctx = createCollabEditor();
const versioning = ctx.editor.getExtension(VersioningExtension)!;
// Create three versions at different points
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Version 1" },
]);
const v1 = await versioning.create!({ name: "v1" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Version 2" },
]);
const v2 = await versioning.create!({ name: "v2" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Version 3" },
]);
await versioning.create!({ name: "v3" });
ctx.editor.replaceBlocks(ctx.editor.document, [
{ type: "paragraph", content: "Current live" },
]);
// Preview older, then newer
await versioning.previewSnapshot(v1.id);
expect(getEditorText(ctx.editor)).toContain("Version 1");
await versioning.previewSnapshot(v2.id);
expect(getEditorText(ctx.editor)).toContain("Version 2");
expect(versioning.store.state.previewedSnapshotId).toBe(v2.id);
// Exit back to live
versioning.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Current live");
});
});