UNPKG

@blocknote/core

Version:

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

264 lines (240 loc) 9.2 kB
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { Block } from "../../blocks/defaultBlocks.js"; import type { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; import type { PreviewController, VersioningEndpoints, VersioningExtensionOptions, VersionSnapshot, } from "./Versioning.js"; import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst } from "./Versioning.js"; /** * Label shown on a diff's marks for the version that introduced the changes. * The previewed snapshot is the "new" side of the diff; the current-version * entry (previewing the live doc) has no name, so it reads "Current version". */ function versionLabel(snapshot: VersionSnapshot): string { if (snapshot.id === CURRENT_VERSION_ID) { return "Current version"; } return snapshot.name ?? "Unnamed version"; } // --------------------------------------------------------------------------- // Preview Controller // --------------------------------------------------------------------------- /** * Create a {@link PreviewController} that swaps the BlockNote document in and * out using `editor.replaceBlocks`. * * When entering preview mode the current document is saved so it can be * restored on exit. Successive `enterPreview` calls without an intervening * `exitPreview` preserve the original saved document. */ export function createInMemoryPreviewController( editor: BlockNoteEditor<any, any, any>, ): PreviewController<Block<any, any, any>[]> { let savedDoc: Block<any, any, any>[] | undefined; // True while a diff (attribution marks) is on screen, so exit/restore knows to // route the cleanup through the diff extension's node-view rebuild. let showingDiff = false; const replaceDoc = (blocks: Block<any, any, any>[]) => { editor.replaceBlocks(editor.document, blocks); }; // The opt-in diff extension, if the consuming editor registered it. Looked up // by key so this module keeps zero runtime dependency on `@y/*`. const getDiff = () => editor.getExtension<typeof DiffVersioningExtension>("diffVersioning"); return { // Comparison is only possible when the (opt-in) diff extension is present — // otherwise previewing a comparison just statically shows the snapshot, so // the UI shouldn't offer it. A getter (not a static `true`) so it's // independent of the order the extensions were registered in: the diff // extension is typically added after the versioning extension, and this is // read lazily (on render) once both are registered. get supportsComparison() { return getDiff() !== undefined; }, enterPreview( snapshotContent: Block<any, any, any>[], compareToContent?: Block<any, any, any>[], _attributions?: unknown, context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, ) { // Save the live doc on first enter (successive enters keep the original). if (savedDoc === undefined) { savedDoc = editor.document; } const diff = getDiff(); if (compareToContent && diff) { // Render a diff of compareTo → snapshot, labelling the changes with the // previewed version's name (the diff's single "author"). diff.renderDiff( snapshotContent, compareToContent, context && versionLabel(context.snapshot), ); showingDiff = true; return; } // No comparison requested, or no diff extension registered: just show the // snapshot content statically. showingDiff = false; replaceDoc(snapshotContent); }, exitPreview() { if (savedDoc !== undefined) { const diff = getDiff(); if (showingDiff && diff) { diff.clearDiff(savedDoc); } else { replaceDoc(savedDoc); } savedDoc = undefined; showingDiff = false; } }, applyRestore(snapshotContent: Block<any, any, any>[]) { const diff = getDiff(); if (showingDiff && diff) { diff.clearDiff(snapshotContent); } else { replaceDoc(snapshotContent); } // Clear saved doc — the restored content is now the live document. savedDoc = undefined; showingDiff = false; }, }; } // --------------------------------------------------------------------------- // Endpoints (in-memory storage) // --------------------------------------------------------------------------- /** * Create a {@link VersioningEndpoints} that stores snapshots entirely in * memory. Useful for local-only / non-collaborative editors where you want * versioning without any persistence layer. * * Snapshots are stored as BlockNote document JSON (`Block[]`). */ export function createInMemoryVersioningEndpoints(): VersioningEndpoints< Block<any, any, any>[], Block<any, any, any>[] > { const snapshots: VersionSnapshot[] = []; const contents = new Map<string, Block<any, any, any>[]>(); let nextId = 1; return { async list() { return sortSnapshotsNewestFirst([...snapshots]); }, async create(currentDoc, options) { const now = Date.now(); const id = String(nextId++); const snapshot: VersionSnapshot = { id, name: options?.name, createdAt: now, updatedAt: now, }; snapshots.push(snapshot); contents.set(id, structuredClone(currentDoc)); return snapshot; }, async restore(currentDoc, snapshot) { // Stored snapshots always have string ids (only the synthetic current // entry carries the symbol, and it never reaches these methods). const id = String(snapshot.id); const snapshotContent = contents.get(id); if (!snapshotContent) { throw new Error(`Snapshot ${id} not found`); } // Create a "Restored from …" snapshot of the current state before // restoring, so the user can undo the restore. const now = Date.now(); const backupId = String(nextId++); const backup: VersionSnapshot = { id: backupId, name: "Before restore", createdAt: now, updatedAt: now, restoredFromSnapshotId: id, }; snapshots.push(backup); contents.set(backupId, structuredClone(currentDoc)); return structuredClone(snapshotContent); }, async getContent(snapshot) { const id = String(snapshot.id); const content = contents.get(id); if (!content) { throw new Error(`Snapshot ${id} not found`); } return structuredClone(content); }, async rename(snapshot, name) { const stored = snapshots.find((s) => s.id === snapshot.id); if (!stored) { throw new Error(`Snapshot ${String(snapshot.id)} not found`); } stored.name = name; stored.updatedAt = Date.now(); }, async remove(snapshot) { const index = snapshots.findIndex((s) => s.id === snapshot.id); if (index === -1) { throw new Error(`Snapshot ${String(snapshot.id)} not found`); } snapshots.splice(index, 1); contents.delete(String(snapshot.id)); }, }; } // --------------------------------------------------------------------------- // Adapter (convenience) // --------------------------------------------------------------------------- /** * Create all the options needed to wire a {@link VersioningExtension} with * fully in-memory storage and BlockNote JSON-based preview. * * @example * ```ts * import { VersioningExtension } from "@blocknote/core/extensions"; * import { createInMemoryVersioningAdapter } from "@blocknote/core/extensions"; * * const editor = BlockNoteEditor.create({ * extensions: [ * VersioningExtension(createInMemoryVersioningAdapter(editor)), * ], * }); * ``` */ export function createInMemoryVersioningAdapter( editor: BlockNoteEditor<any, any, any>, ): VersioningExtensionOptions<Block<any, any, any>[], Block<any, any, any>[]> { const endpoints = createInMemoryVersioningEndpoints(); return { // The raw endpoints are pure snapshot storage. The "current version" is a // view concern owned by the adapter (it's the layer that knows about the // live editor), so we wrap `list()` to always surface a current entry: the // live document is the editable working copy, and the entry is how the user // returns to live editing and compares against saved snapshots. No // timestamp/author is tracked, so the row just reads "Current version" // (see CurrentSnapshot in @blocknote/react). endpoints: { ...endpoints, list: async () => { const current: VersionSnapshot = { id: CURRENT_VERSION_ID, createdAt: Date.now(), updatedAt: Date.now(), }; return [current, ...(await endpoints.list())]; }, }, preview: createInMemoryPreviewController(editor), getCurrentDocument: () => editor.document, // The live document is already in the snapshot content format (`Block[]`), // so previewing "current" as a diff just reuses the live blocks. serializeCurrentContent: () => editor.document, }; }