UNPKG

@blocknote/core

Version:

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

629 lines (593 loc) 24 kB
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension, createStore, type ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; import { normalizeToUserStore, type User, type UserStoreOrResolver, } from "../../user/index.js"; /** * Represents a single snapshot of a document's history, including metadata and content information. * Snapshots are used for versioning and can be created, listed, restored, and previewed through the * {@link VersioningEndpoints}. */ export interface VersionSnapshot { /** * The unique identifier for the snapshot. A plain string for real snapshots; * the {@link CURRENT_VERSION_ID} symbol for the synthetic "Current version" * entry (which no backend ever persists or round-trips). */ id: string | typeof CURRENT_VERSION_ID; /** * The name of the snapshot. */ name?: string; /** * The timestamp when the snapshot was created (unix timestamp). */ createdAt: number; /** * The timestamp when the snapshot was last updated (unix timestamp). */ updatedAt: number; /** * An optional secondary label for the snapshot, which can display additional information such as a custom description. * This is for display purposes only and is not used for any logic in the versioning system. * * For author attribution, prefer {@link by}: it holds raw user ids that the * view layer resolves to user info (and keeps up to date as users load). * When both are set, `secondaryLabel` wins. */ secondaryLabel?: string; /** * The id(s) of the user(s) that authored this version, as raw user ids — * never pre-resolved to display names. The view layer resolves them via the * {@link VersioningExtension}'s user store (see * {@link VersioningExtensionOptions.resolveUsers}), reactively updating as * user info loads. Only used when {@link secondaryLabel} is unset. */ by?: User["id"] | User["id"][]; /** * The ID of the previous snapshot that this snapshot was restored from. */ restoredFromSnapshotId?: string; } /** * Identifier for a single {@link VersionSnapshot}, either the bare id or the * whole reference. Tracks {@link VersionSnapshot.id}, so it also accepts the * {@link CURRENT_VERSION_ID} symbol. */ export type VersionSnapshotIdentifier = | VersionSnapshot["id"] | Pick<VersionSnapshot, "id">; /** * The `id` of the synthetic "Current version" entry — the live document shown at * the top of `list()` and set as `previewedSnapshotId` while previewing it (see * {@link VersioningExtension.previewCurrentVersion}). * * A `unique symbol`, not a string, so it can never clash with a real snapshot id. * It's client-only — never fetched via `getContent` / `getAttributions` (the row * is previewed live) and never serialised, so no backend round-trips it. Because * {@link VersionSnapshot.id} is `string | typeof CURRENT_VERSION_ID`, code that * needs a string form for this one row (e.g. a React `key`) derives it locally. */ export const CURRENT_VERSION_ID: unique symbol = Symbol("bn-current-version"); /** * The backend contract for versioning: **where snapshot data lives** (pure * storage — in-memory, `localStorage`, HTTP, …). Counterpart to * {@link PreviewController} (*how a snapshot is rendered*) and * {@link VersioningExtensionOptions} (*how the live editor is bridged in*); * {@link VersioningExtension} orchestrates the three. * * Type params trace the data flow: * @typeParam Input - Live document handle passed to {@link create} / {@link restore}, * from {@link VersioningExtensionOptions.getCurrentDocument} (e.g. `Y.Type`, `Block[]`). * @typeParam Output - Serialised snapshot content from {@link getContent} / * {@link restore}, rendered by {@link PreviewController.enterPreview} (e.g. `Uint8Array`). * @typeParam Attributions - Optional diff-authorship data from {@link getAttributions}, * also consumed by {@link PreviewController.enterPreview} (e.g. `Y.ContentMap`). */ export interface VersioningEndpoints< Input = any, Output = any, Attributions = any, > { /** * List all snapshots for this document, sorted newest-first by * {@link VersionSnapshot.createdAt}. */ list: () => Promise<VersionSnapshot[]>; /** * Create a new snapshot from the current content. * * @note omit for backends with continuous history (e.g. YHub's activity * timeline). Gates the extension's `canCreate` flag. */ create?: ( /** Live document to snapshot, from {@link VersioningExtensionOptions.getCurrentDocument}. */ content: Input, options?: { /** Optional name for this snapshot. */ name?: string; /** Id of the snapshot this one was restored from, if any. */ restoredFromSnapshot?: VersionSnapshot; }, ) => Promise<VersionSnapshot>; /** * Restore the document to a snapshot. Implementations should create any backup * snapshots they need before returning. * * @returns The restored content ({@link Output}, **not `void`**) — passed to * {@link PreviewController.applyRestore}. * @note omit to disable restore. Gates the extension's `canRestore` flag. */ restore?: ( /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument} (for backup). */ doc: Input, /** The snapshot to restore. */ snapshot: VersionSnapshot, ) => Promise<Output>; /** * Fetch a snapshot's content ({@link Output}) for preview — same format as * {@link VersioningExtensionOptions.serializeCurrentContent}. Sibling of * {@link getAttributions}; both are the storage-side fetch that * {@link PreviewController.enterPreview} renders. */ getContent: (snapshot: VersionSnapshot) => Promise<Output>; /** * Fetch diff-authorship data ({@link Attributions}: who/when) for the range * `compareTo → snapshot`, rendered by {@link PreviewController.enterPreview} * (its only consumer). Lives on the endpoint, not `enterPreview`, so one * preview controller pairs with attribution-capable (YHub) or attribution-less * (`localStorage`) backends — {@link Attributions} is that seam. * * @note omit and previews still render the content diff, minus attribution. */ getAttributions?: ( /** The previewed snapshot (the "new" side of the diff). */ snapshot: VersionSnapshot, /** The baseline it's diffed against (the "old" side). */ compareTo?: VersionSnapshot, ) => Promise<Attributions>; /** * Rename a snapshot. * * @note omit to disable rename. Gates the extension's `canRename` flag. */ rename?: (snapshot: VersionSnapshot, name?: string) => Promise<void>; /** * Permanently remove a snapshot. * * @note omit for immutable-history backends (e.g. YHub). Gates the extension's * `canRemove` flag. */ remove?: (snapshot: VersionSnapshot) => Promise<void>; } /** * A factory function for the endpoints to receive a reference to the editor. * * @typeParam Input - See {@link VersioningEndpoints}. * @typeParam Output - See {@link VersioningEndpoints}. * @typeParam Attributions - See {@link VersioningEndpoints}. */ export type VersioningEndpointsFactory< Input = any, Output = any, Attributions = any, > = ( editor: BlockNoteEditor<any, any, any>, ) => VersioningEndpoints<Input, Output, Attributions>; /** * Controls **how a snapshot is rendered** — the render-side counterpart to * {@link VersioningEndpoints} (storage). {@link VersioningExtension} fetches * content/attributions from the endpoints and delegates rendering here; keeping * the two separate lets one controller pair with different backends. * * @typeParam Output - Serialised snapshot content; matches the endpoints' `Output`. * @typeParam Attributions - Optional attribution data; matches the endpoints' `Attributions`. */ export interface PreviewController<Output = any, Attributions = any> { /** * Whether {@link enterPreview} can render a diff (uses `compareToContent`). * Defaults to `true`; `false` for show-one-version-only backends (e.g. the Yjs * v13 adapter). Surfaced as {@link VersioningExtension.canCompare}. */ supportsComparison?: boolean; /** * Enter preview mode. Arguments come from the endpoints: * {@link VersioningEndpoints.getContent} (content) and * {@link VersioningEndpoints.getAttributions} (attributions). */ enterPreview: ( /** Snapshot to preview ({@link Output}, from {@link VersioningEndpoints.getContent}). */ snapshotContent: Output, /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */ compareToContent?: Output, /** * Diff attributions ({@link Attributions}, from * {@link VersioningEndpoints.getAttributions}). Only meaningful with * `compareToContent`. */ attributions?: Attributions, /** * The snapshot(s) this preview is for (metadata only — the content is * `snapshotContent` / `compareToContent`). Lets a controller label the * preview with e.g. the version's name, without smuggling it through the * {@link Attributions} channel. `snapshot` is the previewed version (the * {@link CURRENT_VERSION_ID} entry when previewing the live document); * `compareTo` is the baseline it's diffed against, if any. */ context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, ) => void; /** Exit preview mode and resume normal editing. */ exitPreview: () => void; /** * Apply restored content to the live document. Called with the {@link Output} * from {@link VersioningEndpoints.restore}, after preview mode has exited. */ applyRestore: (snapshotContent: Output) => void; } /** Sort snapshots newest-first by creation time. */ export function sortSnapshotsNewestFirst( snapshots: VersionSnapshot[], ): VersionSnapshot[] { return [...snapshots].sort((a, b) => b.createdAt - a.createdAt); } /** * Options accepted by the {@link VersioningExtension} — **how the live editor is * bridged in**, alongside the {@link VersioningEndpoints} (storage) and * {@link PreviewController} (rendering). * * @typeParam Input - See {@link VersioningEndpoints}. * @typeParam Output - See {@link VersioningEndpoints}. * @typeParam Attributions - See {@link VersioningEndpoints}. */ export type VersioningExtensionOptions< Input = any, Output = any, Attributions = any, > = { /** * Backend storage for snapshots. */ endpoints: | VersioningEndpoints<Input, Output, Attributions> | VersioningEndpointsFactory<Input, Output, Attributions>; /** * Controls how snapshot previews and restores are rendered in the editor. */ preview: PreviewController<Output, Attributions>; /** * The **live, mutable document handle** ({@link Input}) the backend snapshots * *from* / restores *into*. Passed to {@link VersioningEndpoints.create} and * {@link VersioningEndpoints.restore}. Cf. {@link serializeCurrentContent} (a * detached copy); the two coincide for some backends (in-memory: * `Input === Output === Block[]`) and differ for others (Yjs: `Y.Type` vs `Uint8Array`). */ getCurrentDocument: () => Input; /** * The live document **serialised to snapshot format** ({@link Output}, matching * {@link VersioningEndpoints.getContent}), for diffing the live doc against a * snapshot (see {@link VersioningExtension.previewCurrentVersion}). Cf. * {@link getCurrentDocument} (the live handle). * * @note omit and the UI can't offer a "Current version" diff. Gates the * extension's `canPreviewCurrent` flag. */ serializeCurrentContent?: () => Output | Promise<Output>; /** * Resolve user information for the author ids in {@link VersionSnapshot.by}, * used by the view layer to render version-author labels. * * Either a resolver function (called with the ids of users that are not yet * cached, returning their information — a user store is built from it * internally) or a pre-built user store (see `createUserStore`). Pass the * same store you give the comments/collaboration extensions so a single * de-duped user cache is shared across features. * * @note omit and author ids are displayed as-is. */ resolveUsers?: UserStoreOrResolver; }; function snapshotNotFoundError( id: VersionSnapshotIdentifier | undefined, ): never { const idResolved = typeof id === "object" ? id.id : id; throw new Error(`Snapshot not found: ${String(idResolved)}`); } export const VersioningExtension = createExtension( ({ options: optionsOrFactory, editor, }: ExtensionOptions< | VersioningExtensionOptions | ((editor: BlockNoteEditor<any, any, any>) => VersioningExtensionOptions) >) => { const { endpoints: endpointsRaw, preview, getCurrentDocument, serializeCurrentContent, resolveUsers, } = typeof optionsOrFactory === "function" ? optionsOrFactory(editor) : optionsOrFactory; const endpoints = typeof endpointsRaw === "function" ? endpointsRaw(editor) : endpointsRaw; // With no resolver this is an empty store: `getUser` always misses, so the // view layer falls back to showing the raw ids from `VersionSnapshot.by`. const userStore = normalizeToUserStore(resolveUsers); const store = createStore<{ snapshots: VersionSnapshot[]; /** * The id of the version currently shown in the editor (the "new" side of * a diff). `undefined` means the live, editable document. Is the * {@link CURRENT_VERSION_ID} symbol when previewing the live document as a * read-only diff against a snapshot. */ previewedSnapshotId?: string | typeof CURRENT_VERSION_ID; /** * The id of the snapshot the preview is being diffed against (the * "baseline" / old side). `undefined` when not showing a diff. Always a * real snapshot id (never the current entry), but typed as the same union * as {@link VersionSnapshot.id} since it's copied from one. Used to render * the "Comparing to" indicator in the sidebar. */ compareToSnapshotId?: string | typeof CURRENT_VERSION_ID; }>({ snapshots: [], previewedSnapshotId: undefined, compareToSnapshotId: undefined, }); const getSnapshot = (id: VersionSnapshotIdentifier | undefined) => { const idResolved = typeof id === "object" ? id.id : id; return store.state.snapshots.find( (snapshot) => snapshot.id === idResolved, ); }; const updateSnapshots = async () => { const snapshots = sortSnapshotsNewestFirst(await endpoints.list()); store.setState((state) => ({ ...state, snapshots, })); return snapshots; }; const previewSnapshot = async ( id: VersionSnapshotIdentifier, previewOptions?: { /** * When set, the preview shows a diff against this snapshot (typically the * chronologically previous version in the history list). */ compareTo?: VersionSnapshotIdentifier; }, ) => { const snapshot = getSnapshot(id); if (!snapshot) { snapshotNotFoundError(id); } const compareToSnapshot = previewOptions?.compareTo ? getSnapshot(previewOptions.compareTo) : undefined; store.setState((state) => ({ ...state, previewedSnapshotId: snapshot.id, compareToSnapshotId: compareToSnapshot?.id, })); let compareToContent: unknown; let attributions: unknown; if (compareToSnapshot) { compareToContent = await endpoints.getContent(compareToSnapshot); // Attributions describe the diff between the baseline and this // snapshot, so they're only meaningful when comparing against another // version. Fetching them is optional: previews still render the content // diff without author/timestamp information when unavailable. if (endpoints.getAttributions) { attributions = await endpoints.getAttributions( snapshot, compareToSnapshot, ); } } const snapshotContent = await endpoints.getContent(snapshot); preview.enterPreview(snapshotContent, compareToContent, attributions, { snapshot, compareTo: compareToSnapshot, }); }; /** * Preview the live ("current") document as a read-only diff against a * snapshot baseline. Unlike {@link previewSnapshot}, the "new" side of the * diff is the live document — serialised via `serializeCurrentContent` — * rather than a stored snapshot. The editor becomes non-editable while * previewing (editing is gated on `previewedSnapshotId === undefined`). */ const previewCurrentVersion = async (previewOptions?: { /** * The snapshot to diff the live document against (the baseline). When * omitted, the live document is shown without a diff. */ compareTo?: VersionSnapshotIdentifier; }) => { if (!serializeCurrentContent) { throw new Error( "previewCurrentVersion requires `serializeCurrentContent` to be " + "provided to the VersioningExtension options.", ); } const compareToSnapshot = previewOptions?.compareTo ? getSnapshot(previewOptions.compareTo) : undefined; store.setState((state) => ({ ...state, previewedSnapshotId: CURRENT_VERSION_ID, compareToSnapshotId: compareToSnapshot?.id, })); // Synthesise a snapshot for the live document so timestamp-based backends // (e.g. YHub) resolve the changeset window up to "now", and so the preview // controller gets a snapshot to key off. The id is the current-version // sentinel; backends ignore it and resolve the window from `createdAt`. const currentSnapshot: VersionSnapshot = { id: CURRENT_VERSION_ID, createdAt: Date.now(), updatedAt: Date.now(), }; let compareToContent: unknown; let attributions: unknown; if (compareToSnapshot) { compareToContent = await endpoints.getContent(compareToSnapshot); if (endpoints.getAttributions) { attributions = await endpoints.getAttributions( currentSnapshot, compareToSnapshot, ); } } const currentContent = await serializeCurrentContent(); preview.enterPreview(currentContent, compareToContent, attributions, { snapshot: currentSnapshot, compareTo: compareToSnapshot, }); }; const exitPreview = () => { store.setState((state) => ({ ...state, previewedSnapshotId: undefined, compareToSnapshotId: undefined, })); preview.exitPreview(); }; return { key: "versioning", store, userStore, list: async (): Promise<VersionSnapshot[]> => { return await updateSnapshots(); }, // Comparison is only offered when the preview controller can actually // render a diff (see PreviewController.supportsComparison). A getter so a // controller whose `supportsComparison` is itself dynamic (e.g. gated on // an opt-in diff extension that may be registered after this one) is read // lazily, not captured at init time. get canCompare() { return preview.supportsComparison !== false; }, canCreate: endpoints.create !== undefined, create: endpoints.create ? async (options?: { /** * The optional name for this snapshot. */ name?: string; /** * The ID of the snapshot this one was restored from, if applicable. */ restoredFromSnapshot?: VersionSnapshotIdentifier; }): Promise<VersionSnapshot> => { const snapshot = await endpoints.create!(getCurrentDocument(), { name: options?.name, restoredFromSnapshot: getSnapshot(options?.restoredFromSnapshot), }); // Show the new version immediately. Some backends (e.g. YHub) build // their version list from an activity timeline that lags a beat // behind the create, so waiting on a re-list would leave the UI // briefly stale. store.setState((state) => ({ ...state, snapshots: sortSnapshotsNewestFirst([ ...state.snapshots, snapshot, ]), })); // Reconcile with the backend's `list()` — it owns the "current // version" entry and any server-assigned metadata. If the refreshed // list doesn't include the just-created version yet (indexing lag), // keep the optimistic entry so it never flickers out. const listed = await endpoints.list(); store.setState((state) => ({ ...state, snapshots: sortSnapshotsNewestFirst( listed.some((s) => s.id === snapshot.id) ? listed : [...listed, snapshot], ), })); return snapshot; } : undefined, canRestore: endpoints.restore !== undefined, restore: endpoints.restore ? async (id: VersionSnapshotIdentifier) => { exitPreview(); const snapshot = getSnapshot(id); if (!snapshot) { snapshotNotFoundError(id); } const snapshotContent = await endpoints.restore!( getCurrentDocument(), snapshot, ); preview.applyRestore(snapshotContent); await updateSnapshots(); return snapshotContent; } : undefined, canRename: endpoints.rename !== undefined, rename: endpoints.rename ? async ( id: VersionSnapshotIdentifier, name?: string, ): Promise<void> => { const snapshot = getSnapshot(id); if (!snapshot) { snapshotNotFoundError(id); } await endpoints.rename!(snapshot, name); store.setState((state) => ({ ...state, snapshots: state.snapshots.map((s) => s.id === id ? { ...s, name, updatedAt: Date.now() } : s, ), })); } : undefined, canRemove: endpoints.remove !== undefined, remove: endpoints.remove ? async (id: VersionSnapshotIdentifier): Promise<void> => { const snapshot = getSnapshot(id); if (!snapshot) { snapshotNotFoundError(id); } // If the snapshot being removed is the one currently previewed, or // the baseline it's being diffed against, exit preview first so the // editor returns to the live document instead of showing (or // comparing against) a version that no longer exists. if ( store.state.previewedSnapshotId === snapshot.id || store.state.compareToSnapshotId === snapshot.id ) { exitPreview(); } await endpoints.remove!(snapshot); // Remove it optimistically so the row disappears immediately, then // reconcile with the backend's authoritative list. store.setState((state) => ({ ...state, snapshots: state.snapshots.filter((s) => s.id !== snapshot.id), })); await updateSnapshots(); } : undefined, previewSnapshot, canPreviewCurrent: serializeCurrentContent !== undefined, previewCurrentVersion: serializeCurrentContent ? previewCurrentVersion : undefined, exitPreview, } as const; }, );