@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
470 lines (382 loc) • 15.1 kB
text/typescript
/**
* @vitest-environment jsdom
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js";
import { CURRENT_VERSION_ID, VersioningExtension } from "./Versioning.js";
import {
createInMemoryPreviewController,
createInMemoryVersioningAdapter,
createInMemoryVersioningEndpoints,
} from "./inMemoryVersioning.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createEditor() {
const editor = BlockNoteEditor.create();
const div = document.createElement("div");
editor.mount(div);
return editor;
}
function getEditorText(editor: BlockNoteEditor<any, any, any>): string {
return editor.prosemirrorState.doc.textContent;
}
function setEditorText(editor: BlockNoteEditor<any, any, any>, text: string) {
editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);
}
// ---------------------------------------------------------------------------
// Tests — createInMemoryVersioningEndpoints
// ---------------------------------------------------------------------------
describe("createInMemoryVersioningEndpoints", () => {
it("creates and retrieves snapshots", async () => {
const endpoints = createInMemoryVersioningEndpoints();
const blocks = [
{
id: "1",
type: "paragraph" as const,
content: [] as any,
props: {} as any,
children: [],
},
];
const snap = await endpoints.create!(blocks, { name: "v1" });
expect(snap.name).toBe("v1");
expect(snap.id).toBeDefined();
const content = await endpoints.getContent(snap);
expect(content).toEqual(blocks);
// Content is a deep clone, not a reference
expect(content).not.toBe(blocks);
});
it("lists snapshots newest-first", async () => {
vi.useFakeTimers();
try {
const endpoints = createInMemoryVersioningEndpoints();
const s1 = await endpoints.create!([
{
id: "1",
type: "paragraph" as const,
content: "v1" as any,
props: {} as any,
children: [],
},
]);
vi.advanceTimersByTime(1000);
const s2 = await endpoints.create!([
{
id: "2",
type: "paragraph" as const,
content: "v2" as any,
props: {} as any,
children: [],
},
]);
const list = await endpoints.list();
expect(list[0].id).toBe(s2.id);
expect(list[1].id).toBe(s1.id);
} finally {
vi.useRealTimers();
}
});
it("restore creates a backup and returns snapshot content", async () => {
const endpoints = createInMemoryVersioningEndpoints();
const original = [
{
id: "1",
type: "paragraph" as const,
content: "original" as any,
props: {} as any,
children: [],
},
];
const snap = await endpoints.create!(original);
const currentDoc = [
{
id: "2",
type: "paragraph" as const,
content: "modified" as any,
props: {} as any,
children: [],
},
];
const restored = await endpoints.restore!(currentDoc, snap);
expect(restored).toEqual(original);
// A backup snapshot was created
const list = await endpoints.list();
expect(list.length).toBe(2);
const backup = list.find((s) => s.restoredFromSnapshotId === snap.id);
expect(backup).toBeDefined();
// The backup contains the current (pre-restore) doc
const backupContent = await endpoints.getContent(backup!);
expect(backupContent).toEqual(currentDoc);
});
it("updates snapshot name", async () => {
const endpoints = createInMemoryVersioningEndpoints();
const snap = await endpoints.create!(
[
{
id: "1",
type: "paragraph" as const,
content: "v1" as any,
props: {} as any,
children: [],
},
],
{ name: "old" },
);
await endpoints.rename!(snap, "new");
const list = await endpoints.list();
expect(list.find((s) => s.id === snap.id)!.name).toBe("new");
});
it("deletes a snapshot and its content", async () => {
const endpoints = createInMemoryVersioningEndpoints();
const snap = await endpoints.create!([
{
id: "1",
type: "paragraph" as const,
content: "v1" as any,
props: {} as any,
children: [],
},
]);
await endpoints.remove!(snap);
// No longer listed
expect(await endpoints.list()).toHaveLength(0);
// Its content is gone too
await expect(endpoints.getContent(snap)).rejects.toThrow(/not found/i);
});
it("throws for unknown snapshot ID", async () => {
const endpoints = createInMemoryVersioningEndpoints();
const missing = { id: "nope", createdAt: 0, updatedAt: 0 };
await expect(endpoints.getContent(missing)).rejects.toThrow(/not found/i);
await expect(endpoints.restore!([], missing)).rejects.toThrow(/not found/i);
await expect(endpoints.rename!(missing, "x")).rejects.toThrow(/not found/i);
await expect(endpoints.remove!(missing)).rejects.toThrow(/not found/i);
});
});
// ---------------------------------------------------------------------------
// Tests — createInMemoryPreviewController
// ---------------------------------------------------------------------------
describe("createInMemoryPreviewController", () => {
let editor: BlockNoteEditor<any, any, any>;
beforeEach(() => {
editor = createEditor();
setEditorText(editor, "live content");
});
afterEach(() => {
editor.unmount();
});
it("enterPreview replaces doc and exitPreview restores it", () => {
const preview = createInMemoryPreviewController(editor);
// Grab the snapshot content we want to preview — a doc with different text.
const previewEditor = createEditor();
setEditorText(previewEditor, "snapshot content");
const snapshotBlocks = previewEditor.document;
previewEditor.unmount();
preview.enterPreview(snapshotBlocks);
expect(getEditorText(editor)).toBe("snapshot content");
preview.exitPreview();
expect(getEditorText(editor)).toBe("live content");
});
it("successive enterPreview calls preserve original doc", () => {
const preview = createInMemoryPreviewController(editor);
const mkSnap = (text: string) => {
const e = createEditor();
setEditorText(e, text);
const blocks = e.document;
e.unmount();
return blocks;
};
preview.enterPreview(mkSnap("snap A"));
expect(getEditorText(editor)).toBe("snap A");
preview.enterPreview(mkSnap("snap B"));
expect(getEditorText(editor)).toBe("snap B");
// Exit restores the original live doc, not snap A.
preview.exitPreview();
expect(getEditorText(editor)).toBe("live content");
});
it("applyRestore replaces doc and clears saved state", () => {
const preview = createInMemoryPreviewController(editor);
const mkSnap = (text: string) => {
const e = createEditor();
setEditorText(e, text);
const blocks = e.document;
e.unmount();
return blocks;
};
// Enter preview first
preview.enterPreview(mkSnap("previewed"));
expect(getEditorText(editor)).toBe("previewed");
// Now restore — this is the "apply" step after endpoints.restore returns
preview.applyRestore(mkSnap("restored"));
expect(getEditorText(editor)).toBe("restored");
// exitPreview should be a no-op since savedDoc was cleared
preview.exitPreview();
expect(getEditorText(editor)).toBe("restored");
});
});
// ---------------------------------------------------------------------------
// Tests — Full integration with VersioningExtension
// ---------------------------------------------------------------------------
describe("VersioningExtension + in-memory adapter", () => {
let editor: BlockNoteEditor<any, any, any>;
beforeEach(() => {
editor = createEditor();
setEditorText(editor, "initial doc");
});
afterEach(() => {
editor.unmount();
});
it("create, preview, exit, restore full workflow", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
// 1. Create a snapshot of "initial doc"
const snap1 = await ext.create!({ name: "v1" });
expect(snap1.name).toBe("v1");
// 2. Modify the document
setEditorText(editor, "modified doc");
// 3. Create another snapshot
await ext.create!({ name: "v2" });
// 4. List — both present (the adapter also surfaces a "current version"
// entry, which isn't a stored snapshot).
const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID);
expect(list).toHaveLength(2);
expect(list.map((s) => s.name)).toContain("v1");
expect(list.map((s) => s.name)).toContain("v2");
// 5. Preview the first snapshot
await ext.previewSnapshot(snap1.id);
expect(getEditorText(editor)).toBe("initial doc");
expect(ext.store.state.previewedSnapshotId).toBe(snap1.id);
// 6. Exit preview — back to modified doc
ext.exitPreview();
expect(getEditorText(editor)).toBe("modified doc");
expect(ext.store.state.previewedSnapshotId).toBeUndefined();
// 7. Restore the first snapshot
const restored = await ext.restore!(snap1.id);
expect(restored).toBeDefined();
expect(getEditorText(editor)).toBe("initial doc");
// 8. A backup snapshot was created by the endpoints (plus the adapter's
// "current version" entry, which isn't a stored snapshot).
const afterRestore = (await ext.list()).filter(
(s) => s.id !== CURRENT_VERSION_ID,
);
expect(afterRestore.length).toBe(3);
const backup = afterRestore.find(
(s) => s.restoredFromSnapshotId === snap1.id,
);
expect(backup).toBeDefined();
});
it("preview with compareTo fetches both contents", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap1 = await ext.create!({ name: "baseline" });
setEditorText(editor, "changed doc");
const snap2 = await ext.create!({ name: "current" });
// Preview snap2 compared to snap1. Without the (opt-in) DiffVersioningExtension
// registered, the in-memory preview controller falls back to a static swap:
// it shows the snapshot content and renders no diff marks.
await ext.previewSnapshot(snap2.id, { compareTo: snap1.id });
expect(getEditorText(editor)).toBe("changed doc");
ext.exitPreview();
expect(getEditorText(editor)).toBe("changed doc");
});
it("delete removes the snapshot from the store and backend", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap1 = await ext.create!({ name: "keep" });
setEditorText(editor, "changed doc");
const snap2 = await ext.create!({ name: "remove" });
await ext.list();
expect(ext.canRemove).toBe(true);
await ext.remove!(snap2.id);
// Gone from the optimistic store...
expect(
ext.store.state.snapshots.find((s) => s.id === snap2.id),
).toBeUndefined();
// ...and gone from the backend's authoritative list.
const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID);
expect(list.map((s) => s.id)).toEqual([snap1.id]);
});
it("deleting the previewed snapshot exits preview", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap = await ext.create!({ name: "v1" });
setEditorText(editor, "modified doc");
// Preview the snapshot, then delete the version being previewed.
await ext.previewSnapshot(snap.id);
expect(ext.store.state.previewedSnapshotId).toBe(snap.id);
await ext.remove!(snap.id);
// Preview was exited and the live document restored.
expect(ext.store.state.previewedSnapshotId).toBeUndefined();
expect(getEditorText(editor)).toBe("modified doc");
});
it("rename persists through list refresh", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap = await ext.create!({ name: "draft" });
await ext.rename!(snap.id, "final");
// Store was updated optimistically
expect(ext.store.state.snapshots.find((s) => s.id === snap.id)!.name).toBe(
"final",
);
// Backend also updated (verified via list which calls endpoints.list)
const list = await ext.list();
expect(list.find((s) => s.id === snap.id)!.name).toBe("final");
});
});
// ---------------------------------------------------------------------------
// Tests — diff delegation to the opt-in DiffVersioningExtension
// ---------------------------------------------------------------------------
describe("in-memory versioning + DiffVersioningExtension", () => {
let editor: BlockNoteEditor<any, any, any>;
beforeEach(() => {
editor = BlockNoteEditor.create({
extensions: [DiffVersioningExtension()],
});
editor.mount(document.createElement("div"));
setEditorText(editor, "initial doc");
});
afterEach(() => {
editor.unmount();
});
const attributionMarkCount = () => {
let count = 0;
editor.prosemirrorState.doc.descendants((node) => {
count += node.marks.filter((m) =>
m.type.name.startsWith("y-attributed-"),
).length;
return true;
});
return count;
};
it("previewing with compareTo renders an attributed diff", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap1 = await ext.create!({ name: "baseline" });
setEditorText(editor, "changed doc");
const snap2 = await ext.create!({ name: "current" });
await ext.previewSnapshot(snap2.id, { compareTo: snap1.id });
// The diff extension rendered attribution marks (initial vs changed doc).
expect(attributionMarkCount()).toBeGreaterThan(0);
// Exiting the preview clears the marks and restores the live document.
ext.exitPreview();
expect(attributionMarkCount()).toBe(0);
expect(getEditorText(editor)).toBe("changed doc");
});
it("previewing without compareTo shows content with no diff marks", async () => {
const adapter = createInMemoryVersioningAdapter(editor);
const ext = VersioningExtension(adapter)({ editor });
const snap1 = await ext.create!({ name: "baseline" });
setEditorText(editor, "changed doc");
await ext.previewSnapshot(snap1.id);
expect(getEditorText(editor)).toBe("initial doc");
expect(attributionMarkCount()).toBe(0);
});
});