@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
632 lines (577 loc) • 22 kB
text/typescript
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,
};
};
}