UNPKG

@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
/** * @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); }); });