UNPKG

@blocknote/core

Version:

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

632 lines (577 loc) 22 kB
import * as Y from "@y/y"; import { decodeAny, encodeAny } from "lib0/buffer"; import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst, VersioningEndpointsFactory, type VersioningEndpoints, type VersionSnapshot, } from "../../extensions/Versioning/index.js"; import { uint32 } from "lib0/random"; import { YCursorExtension } from "../extensions/YCursorPlugin.js"; import { YSyncExtension } from "../extensions/YSync.js"; /** * Name of the root {@link Y.Type} map on the live collaboration doc that stores * a mutable `versionId -> name` mapping. Because YHub attributions are * immutable, version names that need to be editable (renamed) live here on the * Y.Doc instead of (or in addition to) the immutable `name` attribution. */ const VERSION_NAMES_MAP = "__bn_version_names"; /** * Options for creating a YHub versioning endpoints instance. */ export interface YHubVersioningOptions { /** * Base URL of the YHub API (e.g. `"https://yhub.example.com"`). * Must **not** include a trailing slash. */ baseUrl: string; /** YHub organisation identifier. */ org: string; /** Document identifier within the organisation. */ docId: string; /** * Optional headers to include in every request (e.g. authentication tokens). */ headers?: Record<string, string>; /** * Maximum number of activity entries to fetch when listing versions. * @default 50 */ activityLimit?: number; /** * When set, forwarded as the `group` query param to the YHub activity API, * controlling whether adjacent edits are grouped into single entries. */ group?: boolean; /** * Maximum gap (in ms) between edits for them to be grouped together. * Forwarded as the `groupMaxGap` query param. * @default 10000 */ groupMaxGap?: number; /** * Maximum total duration (in ms) a single group of edits may span. * When set, forwarded as the `groupMaxDuration` query param. */ groupMaxDuration?: number; // TODO mergeUsers is not in standard yhub, but it exists in our fork. /** * When `true`, adjacent edits are grouped together even when made by * *different* users (their ids accumulate in the grouped entry's `by`). * When `false` (the default), only same-user adjacent edits are merged. * Forwarded as the `mergeUsers` query param. * @default false */ mergeUsers?: boolean; } /** * Shape of a single activity entry returned by the YHub * `GET /activity/{org}/{docId}` endpoint (after `decodeAny`). */ interface YHubActivityEntry { /** Start of the change window (unix-ms timestamp). */ from: number; /** End of the change window (unix-ms timestamp). */ to: number; /** Comma separated list of user-ids that matches the attribution */ by?: string; /** Custom attribution key-value pairs (when `customAttributions=true`). */ customAttributions?: Array<{ k: string; v: string }>; } /** * Shape returned by the YHub `GET /changeset/{org}/{docId}` endpoint (after * `decodeAny`). */ interface YHubChangeset { /** Full Y.Doc state **before** the changeset window. */ prevDoc?: Uint8Array; /** Full Y.Doc state **after** the changeset window. */ nextDoc?: Uint8Array; /** * Encoded {@link Y.ContentMap} describing who authored each change in the * window and when. Present when the changeset is requested with * `attributions=true`. */ attributions?: Uint8Array; } /** * Whether an activity entry is a version marker (created with a `type:version` * custom attribution) as opposed to a plain edit. */ function isVersionEntry(entry: YHubActivityEntry): boolean { return ( entry.customAttributions?.some( (a) => a.k === "type" && a.v === "version", ) ?? false ); } /** * Convert a YHub activity entry into a {@link VersionSnapshot}. * * Version markers (entries with a `type:version` custom attribution) map to * named snapshots: the `id` attribution becomes the snapshot identifier and the * `name` attribution its name. Any other (plain edit) entry maps to a * history-only snapshot with a synthetic `history-<to>-<index>` id and no name. * In both cases the entry's `by` user-ids are passed through raw on * {@link VersionSnapshot.by} — resolving them to user info is the view layer's * job. * * The history id embeds the entry's `index` within the activity response * because YHub can emit multiple activity entries sharing the same `to` * timestamp (e.g. distinct same-`insertAt` patches that grouping did not merge), * and `to` alone would then produce colliding `history-<to>` ids — duplicate * React keys in the sidebar. The `index` disambiguates them. The changeset * lookups (`getContent`/`getAttributions`/`restore`) key off * {@link VersionSnapshot.createdAt} (= `entry.to`), never the id, so embedding * the index in the id is safe. */ function activityToSnapshot( entry: YHubActivityEntry, index: number, ): VersionSnapshot | undefined { const by = entry.by ?.split(",") .map((s) => s.trim()) .filter(Boolean); const byField = by && by.length > 0 ? by : undefined; if (isVersionEntry(entry)) { const id = entry.customAttributions?.find((a) => a.k === "id")?.v; if (id === undefined) { return undefined; } const attributionName = entry.customAttributions?.find( (a) => a.k === "name", )?.v; return { id, name: attributionName, createdAt: entry.to, updatedAt: entry.to, by: byField, }; } return { id: `history-${entry.to}-${index}`, createdAt: entry.to, updatedAt: entry.to, by: byField, }; } async function yhubFetch( url: string, headers: Record<string, string>, init?: RequestInit, ): Promise<ArrayBuffer> { const res = await fetch(url, { ...init, headers: { ...headers, ...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : Array.isArray(init?.headers) ? Object.fromEntries(init.headers) : init?.headers), }, }); if (!res.ok) { throw new Error( `YHub request failed: ${res.status} ${res.statusText} (${url})`, ); } return res.arrayBuffer(); } /** * Create a {@link VersioningEndpoints} implementation backed by the * [YHub](https://github.com/yjs/yhub) HTTP API. * * Versions are created by PATCHing the document with custom attributions * (`type:version` + an optional `name`). The `list` endpoint returns the full * activity timeline, mapping `type:version` markers to named versions and every * other entry to a history-only snapshot, so the sidebar can show both the * named versions and the complete edit history. * * A version's id lives in immutable YHub attributions (`type:version` + `id`), * so it is fixed at creation time. Version *names*, however, are stored in a * mutable `__bn_version_names` map on the live collaboration doc (see * {@link VERSION_NAMES_MAP}), so `rename` is supported and simply updates that * store. * * @example * ```ts * import { withCollaboration } from "@blocknote/core/y"; * import { createYHubVersioningEndpoints } from "@blocknote/core/y"; * * const editor = BlockNoteEditor.create( * withCollaboration({ * collaboration: { * fragment, * user: { name: "Alice", color: "#ff0" }, * provider, * versioningEndpoints: createYHubVersioningEndpoints({ * baseUrl: "https://yhub.example.com", * org: "my-org", * docId: "my-doc", * }), * }, * }), * ); * ``` */ export function createYHubVersioningEndpoints( options: YHubVersioningOptions, ): VersioningEndpointsFactory<Y.Type, Uint8Array, Y.ContentMap> { const { baseUrl, org, docId, headers = {}, activityLimit = 50, group, } = options; const activityUrl = `${baseUrl}/activity/${org}/${docId}`; const changesetUrl = `${baseUrl}/changeset/${org}/${docId}`; const rollbackUrl = `${baseUrl}/rollback/${org}/${docId}`; return (editor) => { /** * The mutable per-id version-name store on the live collaboration doc. * * Returns the root {@link VERSION_NAMES_MAP} map-typed {@link Y.Type}, which * uses `setAttr`/`getAttr` for keyed access (this Yjs fork has a single * unified `Y.Type` rather than a distinct `Y.Map`). `undefined` until the * live doc has been captured from a `create` call. */ const getVersionNamesMap = (): Y.Type | undefined => { const fragment = editor.getExtension<typeof YSyncExtension>("ySync")?.fragment.doc; // `fragment` is undefined until the live doc has been captured (e.g. no // ySync extension attached yet); return undefined rather than throwing so // callers can gracefully fall back to the immutable name attribution. return fragment?.get(VERSION_NAMES_MAP); }; /** * Build the synthetic "current version" snapshot, or `undefined` when the * live document matches the latest saved version (no edits since). * * Both lookups are made here, independently of the grouped `list()` request: * * - the newest activity entry of *any* kind (ungrouped, so its `to` is the * true last-edit time), and * - the newest **version marker** (via the `withCustomAttributions` * server-side filter). * * Deriving the marker time from `list()`'s grouped entries would be wrong: * with grouping (especially `mergeUsers`) the newest marker's group absorbs * the later unsaved edit, so the group's `to` equals the edit's `to` and the * comparison below can never fire. Fetching the marker unmerged avoids that. */ const getCurrentVersionEntry = async (): Promise< VersionSnapshot | undefined > => { const latestParams = new URLSearchParams({ order: "desc", limit: "1", customAttributions: "true", }); const latestVersionParams = new URLSearchParams({ order: "desc", limit: "1", customAttributions: "true", // Server-side filter to `type:version` markers only, so this ignores the // plain edits that would otherwise be the newest entries. withCustomAttributions: "type:version", }); const [latestBuf, latestVersionBuf] = await Promise.all([ yhubFetch(`${activityUrl}?${latestParams}`, headers), yhubFetch(`${activityUrl}?${latestVersionParams}`, headers), ]); const latestEdit = ( decodeAny(new Uint8Array(latestBuf)) as YHubActivityEntry[] )[0]; const latestVersion = ( decodeAny(new Uint8Array(latestVersionBuf)) as YHubActivityEntry[] )[0]; if (!latestEdit || latestEdit.to <= (latestVersion?.to ?? 0)) { return undefined; } // Build the synthetic entry directly rather than via `activityToSnapshot`, // whose `id` comes from a string-typed wire attribution — the current // entry's id is the `CURRENT_VERSION_ID` symbol, not a real version id. const by = latestEdit.by ?.split(",") .map((t) => t.trim()) .filter(Boolean) ?? []; return { id: CURRENT_VERSION_ID, createdAt: latestEdit.to, updatedAt: latestEdit.to, by: by.length > 0 ? by : undefined, }; }; /** * PATCH the current document state to YHub, optionally with custom * attributions. Used both for creating named version markers and for * backing up the document before a restore. */ const patchDoc = async ( fragment: Y.Type, customAttributions: Array<{ k: string; v: any }>, by?: string, ) => { const doc = fragment.doc; if (!doc) { throw new Error( "Cannot patch document: the Y.Type is not attached to a Y.Doc.", ); } // YHub only records custom attributions when they attach to NEW content // that survives its server-side diff. An update-less PATCH is rejected // (400 — "at least one of update or awareness must be present"), and even // if it weren't, there'd be no content for the attributions to ride on, so // no activity entry is created. YHub has no metadata-only marker path. // // So we introduce a tiny piece of novel content for the marker to attach // to: a single insert into a dedicated `__bn_version_markers` fragment that // the editor never renders. A fresh Y.Doc guarantees a clientID/content the // server has never seen, so the diff is non-empty and the attributions land // on it. The reconstructed document at this version's timestamp still // contains the full editor content — this marker only ever lives in the // throwaway fragment. const markerDoc = new Y.Doc(); markerDoc.get("__bn_version_markers", "XmlFragment").insert(0, ["v"]); const update = Y.encodeStateAsUpdate(markerDoc); const body: Record<string, unknown> = { update, customAttributions }; await yhubFetch( `${baseUrl}/ydoc/${org}/${docId}${by ? `?userid=${by}` : ""}`, headers, { method: "PATCH", body: encodeAny(body) as BufferSource, }, ); }; /** * Create a named version marker for the current document state by PATCHing * it with `type:version` custom attributions. */ const create: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["create"] = async (fragment, options) => { const id = String(uint32()); const now = Date.now(); if (options?.name) { getVersionNamesMap()?.setAttr(id, options.name); } const customAttributions: Array<{ k: string; v: string }> = [ { k: "type", v: "version" }, { k: "id", v: id }, ]; if (options?.name) { customAttributions.push({ k: "name", v: options.name }); } const user = editor .getExtension<typeof YCursorExtension>("yCursor") ?.getUser(); await patchDoc(fragment, customAttributions, user?.id); return { id, name: options?.name, createdAt: now, updatedAt: now, by: user?.id, }; }; /** * Reconstruct the full document state as it was at a given `to` timestamp. * * The changeset endpoint builds `nextDoc` purely from the `to` timestamp * range — it ignores `withCustomAttributions` for doc reconstruction (that * filter only scopes the attribution overlay). So historical document state * can only be retrieved by timestamp, never by the version's `id`. */ const getContentAt = async (to: number): Promise<Uint8Array> => { const params = new URLSearchParams({ ydoc: "true", to: String(to), }); const buf = await yhubFetch(`${changesetUrl}?${params}`, headers); const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset; if (!changeset.nextDoc) { throw new Error(`YHub returned no document state at timestamp ${to}.`); } return Y.convertUpdateFormatV1ToV2(changeset.nextDoc); }; /** * Fetch the full document content for a saved version snapshot. * * The snapshot's `createdAt` is the activity entry's `to` timestamp (see * {@link activityToSnapshot}), which is exactly what the changeset API needs. */ const getContent: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["getContent"] = async (snapshot) => { return getContentAt(snapshot.createdAt); }; /** * Fetch the authorship attributions for the changes between two snapshots * (or from the start of the document when `compareTo` is omitted). * * Snapshots carry their `to` timestamp directly in `createdAt`, so no * activity lookup is needed to resolve the changeset window. */ const getAttributions: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["getAttributions"] = async (snapshot, compareTo) => { const to = snapshot.createdAt; const from = compareTo !== undefined ? compareTo.createdAt : 0; const params = new URLSearchParams({ from: String(from), to: String(to), attributions: "true", }); const buf = await yhubFetch(`${changesetUrl}?${params}`, headers); const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset; if (!changeset.attributions) { throw new Error( `YHub returned no attributions for snapshot ${String(snapshot.id)}.`, ); } return Y.decodeContentMap(changeset.attributions); }; /** * Restore the document to a saved version: fetch the target version's * content and roll back everything after it. * * The snapshot's `createdAt` is the activity entry's `to` timestamp. */ const restore: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["restore"] = async (_fragment, snapshot) => { const to = snapshot.createdAt; const snapshotContent = await getContentAt(to); await yhubFetch(`${rollbackUrl}?from=${to}`, headers, { method: "POST", body: encodeAny({ from: to }) as BufferSource, }); return snapshotContent; }; /** * Rename a saved version by updating its entry in the mutable * {@link VERSION_NAMES_MAP} store on the live collaboration doc. * * The version's `id` remains fixed in its immutable YHub attributions — * only the editable name in the map is changed. Passing an empty or * `undefined` name clears the entry (falling back to the immutable `name` * attribution captured at creation time). */ const rename: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["rename"] = async (snapshot, name) => { if (typeof snapshot.id !== "string") { // CURRENT_VERSION_ID (symbol) is not renameable. return; } const map = getVersionNamesMap(); if (!map) { throw new Error( "Cannot rename version: no live collaboration document is available.", ); } if (name === undefined || name === "") { map.deleteAttr(snapshot.id); } else { map.setAttr(snapshot.id, name); } }; /** * List the full version timeline (newest first), plus a synthetic * "current version" entry when the live document has unsaved edits. * * Returns the entire activity timeline: `type:version` markers are mapped * to named snapshots and every other entry to a history-only snapshot (see * {@link activityToSnapshot}), so the sidebar can offer both a "named * versions" and a full "history" view. Author user-ids are passed through * raw on {@link VersionSnapshot.by} — the view layer resolves them to user * info via the versioning extension's user store. */ const list: VersioningEndpoints< Y.Type, Uint8Array, Y.ContentMap >["list"] = async () => { // Read the grouping knobs fresh from `options` so a caller mutating the // object it passed in reconfigures grouping on the next refresh (see the // note where these are deliberately left out of the destructure above). const groupMaxGap = options.groupMaxGap ?? 10000; const groupMaxDuration = options.groupMaxDuration; const mergeUsers = options.mergeUsers; const params = new URLSearchParams({ order: "desc", limit: String(activityLimit), customAttributions: "true", }); // Always send a concrete `groupMaxGap`. Sending // `String(undefined)` here would make the server `parseInt("undefined")` // to NaN, silently disabling grouping — which surfaces every same-`to` // attribution as its own history entry and produces duplicate React keys. params.set("groupMaxGap", String(groupMaxGap)); if (group !== undefined) { params.set("group", String(group)); } if (groupMaxDuration !== undefined) { params.set("groupMaxDuration", String(groupMaxDuration)); } if (mergeUsers !== undefined) { params.set("mergeUsers", String(mergeUsers)); } const buf = await yhubFetch(`${activityUrl}?${params}`, headers); const entries = decodeAny(new Uint8Array(buf)) as YHubActivityEntry[]; const snapshots = sortSnapshotsNewestFirst( entries .map((entry, i) => activityToSnapshot(entry, i)) .filter((s): s is VersionSnapshot => s !== undefined) // Prefer the mutable per-id name from the live doc's // `__bn_version_names` store over the immutable `name` attribution, // so renames (which only mutate that store) are reflected here. .map((snapshot) => { const attributionName = snapshot.name; const mappedName = (typeof snapshot.id === "string" ? (getVersionNamesMap()?.getAttr(snapshot.id) as | string | undefined) : undefined) ?? attributionName; return { ...snapshot, name: mappedName }; }), ); // Surface a "current version" entry when the live document has edits // beyond the most recent saved version marker. `getCurrentVersionEntry` // makes its own unmerged lookups (see there), so it is unaffected by the // grouping/mergeUsers params used for the list above. // // This only re-evaluates when `list()` runs (sidebar open / refresh), // which matches how YHub versions load today. const currentEntry = await getCurrentVersionEntry(); return currentEntry ? [currentEntry, ...snapshots] : snapshots; }; return { list, create, getContent, getAttributions, restore, rename, }; }; }