diff --git a/docs/package.json b/docs/package.json
index 76294747a1..232c29ad47 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -113,7 +113,8 @@
"yjs": "^13.6.27",
"zod": "^4.3.5",
"@blocknote/xl-typst-exporter": "workspace:*",
- "@blocknote/xl-typst-compiler": "workspace:*"
+ "@blocknote/xl-typst-compiler": "workspace:*",
+ "y-prosemirror": "^1.3.7"
},
"devDependencies": {
"@blocknote/code-block": "workspace:*",
diff --git a/examples/07-collaboration/10-suggestion-multi-editor/package.json b/examples/07-collaboration/10-suggestion-multi-editor/package.json
index 683ec57c6d..717caac454 100644
--- a/examples/07-collaboration/10-suggestion-multi-editor/package.json
+++ b/examples/07-collaboration/10-suggestion-multi-editor/package.json
@@ -21,8 +21,8 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@y/protocols": "^1.0.6-rc.1",
- "@y/y": "^14.0.0-rc.23",
- "@y/prosemirror": "^2.0.0-6",
+ "@y/y": "^14.0.0-rc.26",
+ "@y/prosemirror": "^2.0.0-11",
"@y/websocket": "^4.0.0-rc.2"
},
"devDependencies": {
diff --git a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx
index 5725db5d8f..4f4ff0616d 100644
--- a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx
+++ b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx
@@ -25,7 +25,7 @@ provider2.awareness.setLocalStateField("user", {
color: "#6eeb83",
});
-const attrs = new Y.Attributions();
+const attrs = Y.createContentMap();
// Batch timestamps: reuse the same timestamp for edits from the same user
// within a 10-second window of inactivity.
@@ -62,7 +62,7 @@ function getBatchedTimestamp(userName: string): number {
function trackAttributions(
trackedDoc: Y.Doc,
userName: string,
- attributions: Y.Attributions,
+ attributions: Y.ContentMap,
) {
trackedDoc.on(
"update",
@@ -105,7 +105,9 @@ suggestingProvider.awareness.setLocalStateField("user", {
name: "Charlie",
color: "#ffbc42",
});
-const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, { attrs });
+const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, {
+ attributions: attrs,
+});
suggestingRenderer.suggestionMode = false;
const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true });
@@ -117,7 +119,7 @@ suggestionModeProvider.awareness.setLocalStateField("user", {
color: "#ee6352",
});
const suggestionModeRenderer = Y.createDiffRenderer(doc, suggestionModeDoc, {
- attrs,
+ attributions: attrs,
});
suggestionModeRenderer.suggestionMode = true;
@@ -155,7 +157,7 @@ function Editor({
userName,
userColor,
}: {
- fragment: Y.Type;
+ fragment: Y.Node;
provider: { awareness?: Awareness };
renderer?: Y.DiffRenderer;
userName: string;
diff --git a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json
index d04a59bb2e..dd773d8bc7 100644
--- a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json
+++ b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json
@@ -6,6 +6,7 @@
"dependencies": {
"y-websocket": "^2.1.0",
"yjs": "^13.6.27",
- "lib0": "^0.2.99"
+ "lib0": "^0.2.99",
+ "y-prosemirror": "^1.3.7"
}
}
diff --git a/examples/07-collaboration/11-versioning-yjs13/README.md b/examples/07-collaboration/11-versioning-yjs13/README.md
index 3482e62a55..f74f239a4e 100644
--- a/examples/07-collaboration/11-versioning-yjs13/README.md
+++ b/examples/07-collaboration/11-versioning-yjs13/README.md
@@ -2,7 +2,7 @@
This example shows how to use the `VersioningExtension` with collaborative editing using `yjs` (v13). Snapshots are stored in localStorage using Yjs state updates.
-**Try it out:** Edit the document, then click the "Version History" button to open the sidebar. From there you can save snapshots, preview older versions, rename them, and restore them.
+The sidebar opens on a document with a few versions already in its history, so you can preview them, rename them, and restore them right away. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button and press "Save version" to add a version of your own.
**Relevant Docs:**
diff --git a/examples/07-collaboration/11-versioning-yjs13/package.json b/examples/07-collaboration/11-versioning-yjs13/package.json
index 7d1f2d3db0..a8f63e767c 100644
--- a/examples/07-collaboration/11-versioning-yjs13/package.json
+++ b/examples/07-collaboration/11-versioning-yjs13/package.json
@@ -22,7 +22,8 @@
"react-dom": "^19.2.3",
"y-websocket": "^2.1.0",
"yjs": "^13.6.27",
- "lib0": "^0.2.99"
+ "lib0": "^0.2.99",
+ "y-prosemirror": "^1.3.7"
},
"devDependencies": {
"@types/react": "^19.2.3",
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx
index 015b90c6ec..24b0f5d827 100644
--- a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx
+++ b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx
@@ -2,42 +2,61 @@ import "@blocknote/core/fonts/inter.css";
import { withCollaboration } from "@blocknote/core/yjs";
import { VersioningExtension } from "@blocknote/core/extensions";
import { createYjsVersioningAdapter } from "@blocknote/core/yjs";
-import { localStorageEndpoints } from "./localStorageEndpoints";
import {
- BlockNoteViewEditor,
- useCreateBlockNote,
- useExtensionState,
-} from "@blocknote/react";
+ hasStoredVersions,
+ localStorageEndpoints,
+ storeVersions,
+} from "./localStorageEndpoints";
+import { BlockNoteViewEditor, useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
+import { useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { toBase64, fromBase64 } from "lib0/buffer";
-import { VersionHistorySidebar } from "./VersionHistorySidebar";
+import { VersioningSidebar } from "@blocknote/react/versioning";
+import {
+ blocksToUpdate,
+ DAY_MS,
+ LIVE_DOCUMENT,
+ SAMPLE_HISTORY,
+} from "./sampleVersions";
import "./style.css";
const roomName = "blocknote-versioning-yjs-example";
+const FRAGMENT_NAME = "document-store";
// localStorage key for the live ("current version") document. Snapshots are
// persisted separately by `localStorageEndpoints`; this keeps the live doc
// itself across refreshes since the demo has no server-side persistence.
const DOC_STORAGE_KEY = "blocknote-versioning-yjs-current-doc";
const doc = new Y.Doc();
-const fragment = doc.getXmlFragment("document-store");
+const fragment = doc.getXmlFragment(FRAGMENT_NAME);
+
+// Persist the full document state on every change.
+doc.on("update", () => {
+ localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc)));
+});
// Restore the persisted live document before the editor is created, so it
// adopts the stored content instead of starting empty.
const persistedDoc = localStorage.getItem(DOC_STORAGE_KEY);
if (persistedDoc) {
Y.applyUpdate(doc, fromBase64(persistedDoc));
+} else if (!hasStoredVersions()) {
+ // First visit: seed a few named versions so the history has something to
+ // show, and open on the newest state of the same document.
+ storeVersions(
+ SAMPLE_HISTORY.map((version) => ({
+ name: version.name,
+ createdAt: Date.now() - version.daysAgo * DAY_MS,
+ content: blocksToUpdate(version.blocks, FRAGMENT_NAME),
+ })),
+ );
+ Y.applyUpdate(doc, blocksToUpdate(LIVE_DOCUMENT, FRAGMENT_NAME));
}
-// Persist the full document state on every change.
-doc.on("update", () => {
- localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc)));
-});
-
const provider = new WebsocketProvider(
"wss://demos.yjs.dev/ws",
roomName,
@@ -66,22 +85,30 @@ export default function App() {
}),
);
- const { previewedSnapshotId } = useExtensionState(VersioningExtension, {
- editor,
- });
+ const [showSidebar, setShowSidebar] = useState(true);
return (
-
+ {/* No `editable` prop: the sidebar makes the editor read-only for as long
+ as it's open, and restores it on close. */}
+
+ {!showSidebar && (
+
+ )}
-
+ {showSidebar && (
+
+ setShowSidebar(false)} />
+
+ )}
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx
deleted file mode 100644
index 0dfc79dc3f..0000000000
--- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { ComponentProps, useComponentsContext } from "@blocknote/react";
-
-// This component is used to display a selection dropdown with a label. By using
-// the useComponentsContext hook, we can create it out of existing components
-// within the same UI library that `BlockNoteView` uses (Mantine, Ariakit, or
-// ShadCN), to match the design of the editor.
-export const SettingsSelect = (props: {
- label: string;
- items: ComponentProps["FormattingToolbar"]["Select"]["items"];
-}) => {
- const Components = useComponentsContext()!;
-
- return (
-
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx
index 0c300cbf6e..a13b616315 100644
--- a/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx
@@ -1,14 +1,12 @@
-import { VersioningSidebar } from "@blocknote/react";
+import { VersioningSidebar } from "@blocknote/react/versioning";
export function HistorySidebar({ onClose }: { onClose: () => void }) {
return (
);
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts b/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts
index 649bcd236f..b493e444ba 100644
--- a/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts
@@ -77,7 +77,7 @@ export function useDocIndex() {
// browser's index yet. The index is local-only, while doc contents live on
// the collaboration server — so a placeholder entry is enough to open it.
const ensure = useCallback(
- (id: string) => {
+ (id: string, title = "Shared document") => {
const current = readDocs();
if (current.some((d) => d.id === id)) {
return;
@@ -85,7 +85,7 @@ export function useDocIndex() {
const now = Date.now();
current.push({
id,
- title: "Shared document",
+ title,
createdAt: now,
updatedAt: now,
});
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.test.ts b/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.test.ts
new file mode 100644
index 0000000000..193eb9c61a
--- /dev/null
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
+import * as Y from "@y/y";
+import { decodeAny } from "lib0/buffer";
+import { seedSampleDocument } from "./sampleDocument.js";
+
+beforeEach(() => localStorage.clear());
+afterEach(() => {
+ localStorage.clear();
+ vi.unstubAllGlobals();
+});
+
+it.each([false, true])(
+ "replays partial seeding without duplicate content or versions (lost response: %s)",
+ async (lostResponse) => {
+ const remote = new Y.Doc({ gc: false });
+ const requests: Uint8Array[] = [];
+ const urls: string[] = [];
+ let failed = false;
+ vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
+ if (!(init.body instanceof Uint8Array)) {
+ throw new Error("Expected a binary seed update");
+ }
+ urls.push(url);
+ requests.push(init.body);
+ const payload: unknown = decodeAny(init.body);
+ if (
+ typeof payload !== "object" ||
+ payload === null ||
+ !("update" in payload) ||
+ !(payload.update instanceof Uint8Array)
+ ) {
+ throw new Error("Expected an encoded Yjs update");
+ }
+ const fail = !failed && requests.length === 2;
+ if (!fail || lostResponse) {
+ Y.applyUpdate(remote, payload.update);
+ }
+ if (fail) {
+ failed = true;
+ return new Response(null, { status: 503 });
+ }
+ return new Response(null, { status: 200 });
+ });
+ const options = { baseUrl: "https://example.test/api", org: "retry-test" };
+ await expect(seedSampleDocument(options)).rejects.toThrow("503");
+ // Index removal does not remove the durable seed updates or change their IDs.
+ localStorage.removeItem("bn-multi-doc-index");
+ const id = await seedSampleDocument(options);
+ expect(new Set(urls)).toEqual(
+ new Set([`${options.baseUrl}/ydoc/v1/${options.org}/${id}`]),
+ );
+ expect(requests[2]).toEqual(requests[0]);
+ expect(requests[3]).toEqual(requests[1]);
+ expect(remote.get("__bn_versions").toArray()).toHaveLength(3);
+ const contents = remote.get().toJSON();
+ const state = Y.encodeStateVector(remote);
+ await seedSampleDocument(options);
+ expect(remote.get().toJSON()).toEqual(contents);
+ expect(Y.encodeStateVector(remote)).toEqual(state);
+ expect(remote.get("__bn_versions").toArray()).toHaveLength(3);
+ remote.destroy();
+ },
+);
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.ts b/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.ts
new file mode 100644
index 0000000000..110e542bfa
--- /dev/null
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.ts
@@ -0,0 +1,233 @@
+import { BlockNoteEditor, type PartialBlock } from "@blocknote/core";
+import { docDiffToDelta } from "@blocknote/core/y";
+import { docToDelta } from "@y/prosemirror";
+import * as Y from "@y/y";
+import { encodeAny } from "lib0/buffer";
+import { generateRandomId } from "./utils.js";
+
+export const SAMPLE_DOCUMENT_TITLE = "Launch plan";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// Stable ids let previews show edits to the same blocks across versions.
+type SampleBlock = PartialBlock & {
+ id: string;
+ type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem";
+ content: string;
+};
+
+function updateContent(blocks: SampleBlock[], updates: Record) {
+ return blocks.map((block) => ({
+ ...block,
+ content: updates[block.id] ?? block.content,
+ }));
+}
+
+const firstDraft: SampleBlock[] = [
+ {
+ id: "title",
+ type: "heading",
+ props: { level: 2 },
+ content: "Launch plan: Notes 2.0",
+ },
+ {
+ id: "goal",
+ type: "paragraph",
+ content:
+ "Goal: ship the new editor to every workspace before the end of the quarter.",
+ },
+ {
+ id: "milestones",
+ type: "heading",
+ props: { level: 3 },
+ content: "Milestones",
+ },
+ {
+ id: "m1",
+ type: "bulletListItem",
+ content: "Beta with five design partners",
+ },
+ { id: "m3", type: "bulletListItem", content: "Public release" },
+];
+
+const addedDates = updateContent(
+ [
+ ...firstDraft.slice(0, -1),
+ {
+ id: "m2",
+ type: "bulletListItem",
+ content: "Fix the ten most-reported beta issues",
+ },
+ firstDraft[firstDraft.length - 1]!,
+ ],
+ {
+ goal: "Goal: ship the new editor to every workspace before the end of September.",
+ m1: "Beta with five design partners (June)",
+ m3: "Public release (September)",
+ },
+);
+
+const marketingReview: SampleBlock[] = [
+ ...updateContent(addedDates, { m3: "Public release (September 15)" }),
+ {
+ id: "announcement",
+ type: "heading",
+ props: { level: 3 },
+ content: "Announcement",
+ },
+ {
+ id: "announcement-text",
+ type: "paragraph",
+ content:
+ "The blog post and changelog entry go out on release day. The newsletter follows a week later.",
+ },
+];
+
+const liveDocument: SampleBlock[] = [
+ ...updateContent(marketingReview, {
+ goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.",
+ }),
+ {
+ id: "questions",
+ type: "heading",
+ props: { level: 3 },
+ content: "Open questions",
+ },
+ {
+ id: "q1",
+ type: "numberedListItem",
+ content: "Do we keep the old editor available as a fallback?",
+ },
+ {
+ id: "q2",
+ type: "numberedListItem",
+ content: "Who owns the migration guide?",
+ },
+];
+
+const SAMPLE_VERSIONS: Array<{
+ name?: string;
+ daysAgo: number;
+ by: string;
+ blocks: PartialBlock[];
+}> = [
+ { name: "First draft", daysAgo: 9, by: "1", blocks: firstDraft },
+ { name: "Added dates", daysAgo: 6, by: "2", blocks: addedDates },
+ { name: "Marketing review", daysAgo: 2, by: "3", blocks: marketingReview },
+ { daysAgo: 0.1, by: "4", blocks: liveDocument },
+];
+
+type SeedOptions = { baseUrl: string; org: string };
+type SeedPlan = { docId: string; patches: number[][] };
+
+function seedKey(options: SeedOptions) {
+ return `bn-multi-doc-seed:${options.baseUrl}/${options.org}`;
+}
+
+export function hasPendingSampleDocument(options: SeedOptions) {
+ return localStorage.getItem(seedKey(options)) !== null;
+}
+
+function readSeedPlan(raw: string): SeedPlan {
+ const plan: unknown = JSON.parse(raw);
+ if (
+ typeof plan !== "object" ||
+ plan === null ||
+ !("docId" in plan) ||
+ typeof plan.docId !== "string" ||
+ !("patches" in plan) ||
+ !Array.isArray(plan.patches) ||
+ !plan.patches.every(
+ (patch: unknown): patch is number[] =>
+ Array.isArray(patch) &&
+ patch.every(
+ (byte: unknown) =>
+ typeof byte === "number" &&
+ Number.isInteger(byte) &&
+ byte >= 0 &&
+ byte <= 255,
+ ),
+ )
+ ) {
+ throw new Error("Invalid saved sample seed plan");
+ }
+ return { docId: plan.docId, patches: plan.patches };
+}
+
+/**
+ * Replay the same back-dated Yjs updates on every attempt. Replaying the prefix
+ * reconciles partial remote success (including a lost PATCH response) before
+ * sending the remaining updates: Yjs update identities make this idempotent.
+ * Persist before the first request, independently of the local document index.
+ */
+export async function seedSampleDocument(
+ options: SeedOptions,
+): Promise {
+ const key = seedKey(options);
+ const saved = localStorage.getItem(key);
+ const plan = saved ? readSeedPlan(saved) : createSeedPlan();
+ if (!saved) {
+ localStorage.setItem(key, JSON.stringify(plan));
+ }
+ const url = `${options.baseUrl}/ydoc/v1/${options.org}/${plan.docId}`;
+ for (const patch of plan.patches) {
+ const res = await fetch(url, {
+ method: "PATCH",
+ body: new Uint8Array(patch),
+ });
+ if (!res.ok) {
+ throw new Error(
+ `YHub seed request failed: ${res.status} ${res.statusText} (${url})`,
+ );
+ }
+ }
+ return plan.docId;
+}
+
+function createSeedPlan(): SeedPlan {
+ const patches: number[][] = [];
+ const ydoc = new Y.Doc({ gc: false });
+ // The same root type the editor syncs (`doc.get()` in `DocumentEditor`).
+ const fragment = ydoc.get();
+ const versions = ydoc.get("__bn_versions");
+
+ let previous: BlockNoteEditor["prosemirrorState"]["doc"] | undefined;
+ let sent = Y.encodeStateVector(ydoc);
+ for (const version of SAMPLE_VERSIONS) {
+ // A headless editor turns the blocks into a ProseMirror document; the
+ // delta from the previous version is what this edit writes.
+ const pmDoc = BlockNoteEditor.create({ initialContent: version.blocks })
+ .prosemirrorState.doc;
+ const at = Math.floor(Date.now() - version.daysAgo * DAY_MS);
+ ydoc.transact(() => {
+ if (previous) {
+ fragment.applyDelta(docDiffToDelta(previous, pmDoc));
+ } else {
+ fragment.applyDelta(docToDelta(pmDoc));
+ }
+ if (version.name !== undefined) {
+ versions.push([{ id: at, name: version.name }] as never);
+ }
+ });
+ previous = pmDoc;
+
+ // Only what this version added, in the V1 format YHub speaks.
+ const update = Y.convertUpdateFormatV2ToV1(
+ Y.encodeStateAsUpdateV2(ydoc, sent),
+ );
+ sent = Y.encodeStateVector(ydoc);
+
+ patches.push(
+ Array.from(
+ encodeAny({
+ update,
+ by: version.by,
+ at,
+ customAttributions: [],
+ }),
+ ),
+ );
+ }
+ ydoc.destroy();
+ return { docId: generateRandomId(6), patches };
+}
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/style.css b/examples/07-collaboration/12-multi-doc-versioning/src/style.css
index d1afbabe0e..ea1b2eba24 100644
--- a/examples/07-collaboration/12-multi-doc-versioning/src/style.css
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/style.css
@@ -747,9 +747,9 @@ button {
.bn-snapshot-name:focus {
outline: none;
}
-.bn-snapshot-name::placeholder {
- color: var(--text-subtle);
-}
+/* No placeholder override: an unnamed row's placeholder ("Current version",
+ or its date) is the row's title, so it keeps the row's text color — white
+ on the selected row. */
.bn-snapshot-date {
color: var(--text-subtle);
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts b/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts
index 5afb4ca869..b8e5f7eeae 100644
--- a/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts
@@ -14,34 +14,37 @@ export function getById(id: string): User {
// Integer-like ids make it obvious if username resolution ever breaks: the UI
// would show a bare number (e.g. "1") instead of a name.
+// The light/dark pairs are BlockNote's own attribution palette
+// (`userColorPalette`), so the marks look the same as they would for a user
+// with no color of their own.
export const USERS: User[] = [
{
id: "1",
username: "Alice",
avatarUrl: "",
- color: "#e6194b",
- colorLight: "#e6194b33",
+ color: "#3b3f9c",
+ colorLight: "#dcdefc",
},
{
id: "2",
username: "Bob",
avatarUrl: "",
- color: "#3cb44b",
- colorLight: "#3cb44b33",
+ color: "#0f6e62",
+ colorLight: "#c9efe9",
},
{
id: "3",
username: "Charlie",
avatarUrl: "",
- color: "#f58231",
- colorLight: "#f5823133",
+ color: "#1e4fb0",
+ colorLight: "#c9dcff",
},
{
id: "4",
username: "Dana",
avatarUrl: "",
- color: "#4363d8",
- colorLight: "#4363d833",
+ color: "#6b2fa3",
+ colorLight: "#eadcfb",
},
];
diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/yhub.ts b/examples/07-collaboration/12-multi-doc-versioning/src/yhub.ts
new file mode 100644
index 0000000000..9ca4c9d41c
--- /dev/null
+++ b/examples/07-collaboration/12-multi-doc-versioning/src/yhub.ts
@@ -0,0 +1,5 @@
+// YHub serves both real-time sync (over WebSocket) and version history (over
+// HTTP) for the same documents, all under its `/api` prefix.
+export const YHUB_HOST = "yhub.teleportal.tools";
+export const YHUB_API_URL = `https://${YHUB_HOST}/api`;
+export const YHUB_WS_URL = `wss://${YHUB_HOST}/api/ws/v1`;
diff --git a/examples/07-collaboration/13-versioning-yjs14/README.md b/examples/07-collaboration/13-versioning-yjs14/README.md
index c27eecc8c2..d2342fc367 100644
--- a/examples/07-collaboration/13-versioning-yjs14/README.md
+++ b/examples/07-collaboration/13-versioning-yjs14/README.md
@@ -1,8 +1,8 @@
# YHub Versioning (@y/y v14)
-This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Snapshots are stored in localStorage using Yjs v2 state updates.
+This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Version history comes from [YHub](https://github.com/yjs/yhub), which records every edit and groups them into versions.
-**Try it out:** Edit the document, then click the "Version History" button to open the sidebar. From there you can save snapshots, preview older versions, rename them, and restore them.
+The sidebar opens on a document seeded with several versions by several users, so you can preview them, compare them, rename them, and restore them right away. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button.
**Relevant Docs:**
diff --git a/examples/07-collaboration/13-versioning-yjs14/package.json b/examples/07-collaboration/13-versioning-yjs14/package.json
index cce229a35b..fca21299a3 100644
--- a/examples/07-collaboration/13-versioning-yjs14/package.json
+++ b/examples/07-collaboration/13-versioning-yjs14/package.json
@@ -20,11 +20,11 @@
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
- "@y/prosemirror": "^2.0.0-6",
+ "@y/prosemirror": "^2.0.0-11",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
- "@y/y": "^14.0.0-rc.23",
- "lib0": "1.0.0-rc.22"
+ "@y/y": "^14.0.0-rc.26",
+ "lib0": "1.0.0-rc.32"
},
"devDependencies": {
"@types/react": "^19.2.3",
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/App.tsx b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx
index 0f2ae9ea48..4a0ebb115c 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/App.tsx
+++ b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx
@@ -1,16 +1,10 @@
+import { VersioningSidebar } from "@blocknote/react/versioning";
import "@blocknote/core/fonts/inter.css";
import {
createYHubVersioningEndpoints,
withCollaboration,
} from "@blocknote/core/y";
-import { VersioningExtension } from "@blocknote/core/extensions";
-import {
- BlockNoteViewEditor,
- useCreateBlockNote,
- useExtension,
- useExtensionState,
- VersioningSidebar,
-} from "@blocknote/react";
+import { BlockNoteViewEditor, useCreateBlockNote } from "@blocknote/react";
import { useEffect, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
@@ -19,7 +13,7 @@ import * as Y from "@y/y";
import { WebsocketProvider } from "@y/websocket";
import { seedSampleVersions } from "./sampleDocument";
-import { resolveUsers } from "./userdata";
+import { resolveUsers, USERS } from "./userdata";
import "./style.css";
// YHub serves both real-time sync (over WebSocket) and version history (over
@@ -30,82 +24,31 @@ const docId = `blocknote-version-yjs14-${Math.floor(Date.now())}`;
const DAY_MS = 24 * 60 * 60 * 1000;
+// Who this tab is editing as. The same id goes to YHub (as the websocket's
+// `userid`, which is what it attributes edits to) and to the editor's cursor,
+// so this session's own edits resolve to "Alice" in the history sidebar rather
+// than to a bare id.
+const currentUser = USERS[0];
+
// YHub-backed versioning endpoints. YHub stores continuous edit history and
// exposes its activity timeline as versions through BlockNote's versioning UI.
// Constructing this opens no connection, so it's safe to do before seeding.
-//
-// The options are held in a mutable object so the "Configuration" panel below
-// can retune `groupMaxGap`/`groupMaxDuration` live: `list()` reads those two
-// fields fresh on each call, so writing new values here + re-running `list()`
-// reshapes the history sidebar against the same document, no editor recreation.
const versioningOptions = {
baseUrl: `https://${yhubHost}/api`,
org,
docId,
- // The seeded history has a few hundred edits; a high limit lets the sidebar
- // render *all* the grouped entries, so dragging groupMaxGap visibly grows and
- // shrinks the list instead of always bumping against a low cap.
- activityLimit: 500,
- // Open already well-grouped (~one row per version) so the history reads as a
- // short, understandable list; dragging groupMaxGap down explodes it.
- groupMaxGap: 1 * DAY_MS,
- groupMaxDuration: undefined as number | undefined,
- // Whether adjacent edits by *different* users merge into one entry.
- mergeUsers: true,
+ activityParams: {
+ // The seeded history has a few hundred edits; a high limit lets the
+ // sidebar render all the grouped entries.
+ limit: "500",
+ // The seeded history spans weeks with days between versions (see
+ // `snapshotBuilder`), so a day-wide grouping window is what makes it read
+ // as one row per version. Real documents want the 1 h default.
+ groupMaxGap: String(1 * DAY_MS),
+ },
};
const versioningEndpoints = createYHubVersioningEndpoints(versioningOptions);
-// Common "nice" gap values spanning 30 seconds → 1 week, offered as a dropdown.
-// The seeded history spans a few weeks with inter-edit gaps skewed toward hours
-// and days (see snapshotBuilder), so this ladder of values lets each choice
-// meaningfully re-shape the list: the hour values pull apart within-version
-// edits, the day values collapse whole versions. A dropdown (vs. a slider) makes
-// the exact value obvious and picking a specific one a single click.
-const SECOND_MS = 1000;
-const MINUTE_MS = 60 * SECOND_MS;
-const HOUR_MS = 60 * MINUTE_MS;
-const GROUP_GAP_STOPS = [
- 30 * SECOND_MS,
- 1 * MINUTE_MS,
- 2 * MINUTE_MS,
- 5 * MINUTE_MS,
- 10 * MINUTE_MS,
- 15 * MINUTE_MS,
- 30 * MINUTE_MS,
- 1 * HOUR_MS,
- 2 * HOUR_MS,
- 4 * HOUR_MS,
- 6 * HOUR_MS,
- 12 * HOUR_MS,
- 1 * DAY_MS,
- 2 * DAY_MS,
- 3 * DAY_MS,
- 5 * DAY_MS,
- 7 * DAY_MS,
-];
-
-/** The stop value nearest to `ms` (for snapping an arbitrary value to an option). */
-const nearestStop = (ms: number) =>
- GROUP_GAP_STOPS.reduce((best, stop) =>
- Math.abs(stop - ms) < Math.abs(best - ms) ? stop : best,
- );
-
-const formatMs = (ms: number) => {
- if (ms >= DAY_MS) {
- const days = ms / DAY_MS;
- return `${days.toFixed(days % 1 === 0 ? 0 : 1)}d`;
- }
- if (ms >= HOUR_MS) {
- const hours = ms / HOUR_MS;
- return `${hours.toFixed(hours % 1 === 0 ? 0 : 1)}h`;
- }
- if (ms >= MINUTE_MS) {
- const mins = ms / MINUTE_MS;
- return `${mins.toFixed(mins % 1 === 0 ? 0 : 1)}m`;
- }
- return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`;
-};
-
const doc = new Y.Doc();
const provider = new WebsocketProvider(
`wss://${yhubHost}/api/ws/v1`,
@@ -113,7 +56,7 @@ const provider = new WebsocketProvider(
doc,
{
params: {
- userid: "test",
+ userid: currentUser.id,
},
},
);
@@ -127,13 +70,37 @@ const preparePromise: Promise = (async () => {
// Seed only when the synced document is genuinely empty.
if (!(doc.get("bn").length > 0)) {
provider.disconnect();
- await seedSampleVersions({
+ const versions = await seedSampleVersions({
baseUrl: `https://${yhubHost}/api`,
org,
docId,
fragment: "bn",
});
provider.connect();
+ // Reconnecting starts a new sync. Do not mount the editor against the
+ // still-empty local document while the seeded content is in flight.
+ if (!provider.synced) {
+ await new Promise((resolve) => {
+ function onSync(synced: boolean) {
+ if (synced) {
+ provider.off("sync", onSync);
+ resolve();
+ }
+ }
+ provider.on("sync", onSync);
+ });
+ }
+
+ // Version *names* aren't part of YHub's history: they live in a
+ // `__bn_versions` array on the live document, keyed by the server timestamp
+ // they label (see `createYHubVersioningEndpoints`). The seeder returns each
+ // version's last-edit timestamp, which is exactly that key.
+ doc.get("__bn_versions").push(
+ versions.map((version) => ({
+ id: version.to,
+ name: version.name,
+ })) as never,
+ );
}
})();
@@ -176,7 +143,11 @@ function VersionedEditor() {
collaboration: {
provider: provider ?? undefined,
fragment: doc.get("bn"),
- user: { color: "#ff0000", name: "User" },
+ user: {
+ id: currentUser.id,
+ color: currentUser.color ?? "#ff0000",
+ name: currentUser.username,
+ },
// Pass versioningEndpoints to the v14 CollaborationExtension which
// automatically wires up the VersioningExtension with the Yjs adapter.
versioningEndpoints,
@@ -187,58 +158,14 @@ function VersionedEditor() {
}),
);
- const { previewedSnapshotId } = useExtensionState(VersioningExtension, {
- editor,
- });
-
const [showSidebar, setShowSidebar] = useState(true);
- const versioning = useExtension(VersioningExtension, { editor });
- useEffect(() => {
- versioning.list();
- const interval = setInterval(() => {
- versioning.list();
- }, 10000);
- return () => {
- clearInterval(interval);
- };
- }, [versioning]);
-
- // Local mirror of the grouping knobs. Changing any writes the value back onto
- // the shared `versioningOptions` object and re-runs `list()` so the history
- // sidebar re-groups immediately (the core `list()` reads these live).
- const [showSettings, setShowSettings] = useState(false);
- const [groupMaxGap, setGroupMaxGap] = useState(versioningOptions.groupMaxGap);
- const [groupMaxDuration, setGroupMaxDuration] = useState(
- versioningOptions.groupMaxDuration,
- );
- const [mergeUsers, setMergeUsers] = useState(versioningOptions.mergeUsers);
-
- const applyGroupMaxGap = (value: number) => {
- setGroupMaxGap(value);
- versioningOptions.groupMaxGap = value;
- versioning.list();
- };
-
- const applyGroupMaxDuration = (value: number | undefined) => {
- setGroupMaxDuration(value);
- versioningOptions.groupMaxDuration = value;
- versioning.list();
- };
-
- const applyMergeUsers = (value: boolean) => {
- setMergeUsers(value);
- versioningOptions.mergeUsers = value;
- versioning.list();
- };
-
return (
-
+ {/* The sidebar makes the editor read-only for as long as it is open —
+ that's the versioning extension's job, so there's no `editable` prop
+ to manage here. */}
+
@@ -250,80 +177,6 @@ function VersionedEditor() {
History
)}
-
- {showSettings && (
-
-
Configuration
-
-
-
- {/* A dropdown of common gap values (30s → 1week). Pick one and
- the history re-groups against the live document. */}
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
{showSidebar && (
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts
index 78a637204e..4d499448fb 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts
+++ b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts
@@ -3,6 +3,7 @@ import { BlockNoteEditor } from "@blocknote/core";
import { buildEditHistory } from "./snapshotBuilder";
import type { EditHistoryStep } from "./snapshotBuilder";
import { seedYHubDocument } from "./seed";
+import type { SeededVersion } from "./seed";
import { VERSIONS } from "./versions";
/**
@@ -26,8 +27,8 @@ import { VERSIONS } from "./versions";
*
* Each emitted op becomes its own captured transaction, attributed to one of
* the version's authors at random, and `seedYHubDocument` lands them as
- * separate authored content before committing a single version marker — so the
- * one version is attributed to several authors.
+ * separate authored content — so grouping merges them back into one version
+ * attributed to several authors.
*/
/** Each version's target tree plus the 2–3 users who collaborate on it. */
@@ -61,19 +62,22 @@ const VERSION_PLAN: EditHistoryStep[] = [
/**
* Build the sample document's history offline and seed it to YHub under the
* given coordinates, so the live editor syncs the content and the version
- * sidebar shows one snapshot per step.
+ * sidebar shows one version per step.
*
* The `fragment` must match the key the live editor reads (`doc.get(fragment)`).
+ *
+ * @returns each version's name and the server timestamp it should be named
+ * against — the caller writes those into the live doc's `__bn_versions` array.
*/
export async function seedSampleVersions(opts: {
baseUrl: string;
org: string;
docId: string;
fragment: string;
-}): Promise {
+}): Promise {
const editor = BlockNoteEditor.create();
const build = await buildEditHistory(editor, VERSION_PLAN, {
fragment: opts.fragment,
});
- await seedYHubDocument(opts, build);
+ return seedYHubDocument(opts, build);
}
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts
index 52e1779274..a0aea349f7 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts
+++ b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts
@@ -18,9 +18,13 @@ export interface SeedYHubDocumentOptions {
headers?: Record;
}
-/** A version marker created on the server while seeding. */
+/** A named version produced by seeding. */
export interface SeededVersion {
- id: string;
+ /**
+ * The version's server timestamp — the `to` of its last seeded edit, and so
+ * the key its name is stored under in the live doc's `__bn_versions` array.
+ */
+ to: number;
name: string;
}
@@ -42,45 +46,32 @@ type YHubPatch = {
by?: string;
/** Timestamp override (unix ms), so backfilled history stays ordered. */
at?: number;
- /** Custom attributions riding this patch's content (e.g. version markers). */
+ /** Custom attributions riding this patch's content. */
customAttributions?: Array<{ k: string; v: string }>;
};
-/** Build the throwaway novel content a version marker rides on (see yhub.ts `patchDoc`). */
-function makeVersionMarkerUpdate(): Uint8Array {
- // YHub only records custom attributions when they attach to NEW content that
- // survives its server-side diff. The version's real content was already
- // PATCHed (attributed to individual users), so the marker needs its own scrap
- // of novel content: a single insert into a dedicated `__bn_version_markers`
- // fragment the editor never renders. A fresh Y.Doc guarantees a clientID the
- // server has never seen, so the diff is non-empty and the marker lands.
- const markerDoc = new Y.Doc();
- markerDoc.get("__bn_version_markers", "XmlFragment").insert(0, ["v"]);
- return Y.encodeStateAsUpdate(markerDoc);
-}
-
/**
* Pre-populate a YHub document with content **and** version history from a
* {@link buildEditHistory} result, without a live editor / sync connection.
*
- * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` as a
- * single ordered `patches` bulk request: one content patch per captured
- * transaction (attributed via `by`, **no** version marker), followed by one
- * marker patch carrying a `type:version` custom attribution — the same marker
- * {@link createYHubVersioningEndpoints}'s `create` uses. Because the version's
- * attribution window spans all of its content patches, **multiple users are
- * attributed within the one version**. The starting document state
- * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first, without a
- * marker, so the step patches have their baseline to merge onto.
+ * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}`
+ * as a single ordered `patches` bulk request: one content patch per captured
+ * transaction, attributed via `by`. Nothing marks a version on the server —
+ * YHub's history *is* the version list — so a version is simply a run of edits
+ * separated from the next by a large gap, which is why **multiple users end up
+ * attributed within one version**. The starting document state
+ * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first so the step
+ * patches have their baseline to merge onto.
*
* Every patch carries the explicit `at` timestamp captured by
* {@link buildEditHistory}, so the backfilled history stays deterministically
- * ordered (content before its marker, each version after the previous one).
+ * ordered (each version after the previous one).
*
* YHub speaks the V1 update format, so the V2 updates `buildEditHistory`
- * produces are converted; the synthetic marker update is already V1.
+ * produces are converted.
*
- * @returns the version markers created, in order.
+ * @returns each version's name and its last edit's timestamp, in order — the
+ * caller writes those into the live doc's `__bn_versions` array to name them.
*
* @example
* ```ts
@@ -113,17 +104,18 @@ export async function seedYHubDocument(
}
};
- // 1. Starting document state — content only, no version marker. Timestamp it
- // just before the first captured transaction so it sorts first.
+ // 1. Starting document state. Timestamp it just before the first captured
+ // transaction so it sorts first.
await send({
update: Y.convertUpdateFormatV2ToV1(build.baseUpdate),
at: build.steps[0]?.patches[0]?.at ?? Date.now(),
customAttributions: [],
});
- // 2. Each step: one content patch per captured transaction, then a single
- // `type:version` marker patch so it appears as one snapshot attributed to
- // every author.
+ // 2. Each step: one content patch per captured transaction. The step's last
+ // edit ends its group (the next version is days away, well past the
+ // example's `groupMaxGap`), so `step.at` is the timestamp the version's
+ // name attaches to.
const versions: SeededVersion[] = [];
for (const step of build.steps) {
const patches: YHubPatch[] = step.patches.map((p) => ({
@@ -132,22 +124,9 @@ export async function seedYHubDocument(
at: p.at,
customAttributions: [],
}));
- // The marker patch carries the version itself. YHub attributes an entry to a
- // single user, so credit the version to its last contributor (the per-content
- // attribution still records who authored each part).
- patches.push({
- update: makeVersionMarkerUpdate(),
- by: step.by,
- at: step.at,
- customAttributions: [
- { k: "type", v: "version" },
- { k: "id", v: step.id },
- { k: "name", v: step.name },
- ],
- });
await send({ patches });
- versions.push({ id: step.id, name: step.name });
+ versions.push({ to: step.at, name: step.name });
}
return versions;
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts
index de104ba037..db4bb3bf4e 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts
+++ b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts
@@ -2,7 +2,6 @@ import { BlockNoteEditor } from "@blocknote/core";
import { docDiffToDelta } from "@blocknote/core/y";
import { docToDelta } from "@y/prosemirror";
import * as Y from "@y/y";
-import { uint32 } from "lib0/random";
import { applyVersionUnbatched, type VersionBlock } from "./reconcile";
@@ -13,10 +12,11 @@ import { applyVersionUnbatched, type VersionBlock } from "./reconcile";
* reconciles the *same* editor instance towards a target document, producing a
* burst of ProseMirror transactions. We capture every content-changing
* transaction, diff its before/after ProseMirror docs (`docDiffToDelta`), apply
- * that delta to a plain Y.Type in its own Yjs transaction (tagged with a random
+ * that delta to a plain Y.Node in its own Yjs transaction (tagged with a random
* author as origin), and record the resulting V2 update. The captured updates
- * can later be PATCHed to a server (see seed.ts) to rebuild the history, with a
- * `type:version` marker committed at the end of each step.
+ * can later be PATCHed to a server (see seed.ts) to rebuild the history: each
+ * step's edits are separated from the next step's by a large gap, which is what
+ * makes them read as one version.
*
* The backing Y.Doc has gc disabled so history stays reconstructable.
*/
@@ -50,7 +50,6 @@ export type BuildEditHistoryResult = {
/** One entry per step, in order, each carrying its captured transactions. */
steps: Array<{
name: string;
- id: string;
by?: string;
at: number;
patches: CapturedPatch[];
@@ -139,8 +138,8 @@ export async function buildEditHistory(
const ydoc = new Y.Doc({ gc: false });
const yType = ydoc.get(options.fragment);
- // Seed the Y.Type with the editor's starting doc so that every subsequent
- // diff is relative to a Y.Type that actually mirrors the editor. Capture the
+ // Seed the Y.Node with the editor's starting doc so that every subsequent
+ // diff is relative to a Y.Node that actually mirrors the editor. Capture the
// empty state vector first so we can expose the seed as `baseUpdate`.
const emptyStateVector = Y.encodeStateVector(ydoc);
ydoc.transact(() => {
@@ -215,9 +214,8 @@ export async function buildEditHistory(
});
resultSteps.push({
name: step.name,
- id: String(uint32()),
by: lastAuthor,
- // Marker right after this version's last edit.
+ // This version's last edit, which is what its name attaches to.
at: Math.floor(clock),
patches,
});
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/style.css b/examples/07-collaboration/13-versioning-yjs14/src/style.css
index 7edddb292a..a1ee1379c0 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/style.css
+++ b/examples/07-collaboration/13-versioning-yjs14/src/style.css
@@ -67,108 +67,6 @@
padding: 8px;
}
-/* Floating gear button pinned to the bottom-left of the viewport, opening the
- live grouping "Configuration" panel. Styled like `.show-history-button`.
- Both the gear and the panel are `position: fixed` with a very high z-index so
- they stay above BlockNote's floating UI (toolbars, menus), which is portaled
- to `document.body` and would otherwise pop up over an in-flow panel. */
-.config-gear-button {
- align-items: center;
- background-color: var(--bn-colors-menu-background);
- border: var(--bn-border);
- border-radius: 50%;
- box-shadow: var(--bn-shadow-medium);
- bottom: 16px;
- color: var(--bn-colors-menu-text);
- cursor: pointer;
- display: flex;
- font-size: 18px;
- height: 40px;
- justify-content: center;
- left: 16px;
- line-height: 1;
- position: fixed;
- width: 40px;
- z-index: 99999;
-}
-
-.config-panel {
- background-color: var(--bn-colors-menu-background);
- border: var(--bn-border);
- border-radius: var(--bn-border-radius-medium);
- bottom: 68px;
- box-shadow: var(--bn-shadow-medium);
- color: var(--bn-colors-menu-text);
- display: flex;
- flex-direction: column;
- gap: 14px;
- left: 16px;
- padding: 14px;
- position: fixed;
- width: 260px;
- z-index: 99999;
-}
-
-.config-panel-title {
- font-size: 13px;
- font-weight: 600;
-}
-
-.config-row {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.config-row > label {
- align-items: baseline;
- display: flex;
- font-size: 12px;
- font-weight: 500;
- justify-content: space-between;
-}
-
-.config-row .config-value {
- color: var(--bn-colors-menu-text);
- font-weight: 400;
- opacity: 0.7;
-}
-
-.config-row input[type="range"] {
- width: 100%;
-}
-
-.config-row input[type="number"],
-.config-select {
- background: var(--bn-colors-editor-background);
- border: 1px solid var(--bn-colors-border);
- border-radius: var(--bn-border-radius-small);
- color: var(--bn-colors-menu-text);
- font-size: 12px;
- padding: 4px 6px;
- width: 100%;
-}
-
-.config-unlimited {
- align-items: center;
- display: flex;
- font-size: 12px;
- gap: 6px;
-}
-
-/* The mergeUsers boolean row: checkbox + label + a muted hint on the right. */
-.config-toggle {
- align-items: center;
- display: flex;
- font-size: 12px;
- font-weight: 500;
- gap: 6px;
-}
-
-.config-toggle .config-value {
- margin-left: auto;
-}
-
.show-history-button {
background-color: var(--bn-colors-menu-background);
border: var(--bn-border);
@@ -200,36 +98,3 @@
line-height: 12px;
padding-left: 14px;
}
-
-/* The versioning sidebar's tab switcher (Named Versions / Version History).
- The theme stylesheets (@blocknote/mantine etc.) also style these, but this
- example imports the editor without a single theme stylesheet in scope for
- the sidebar, so the tab rules are repeated here to guarantee they're styled. */
-.bn-versioning-sidebar-tabs {
- border-bottom: 1px solid var(--bn-colors-border);
- display: flex;
- gap: 4px;
- margin-bottom: 8px;
-}
-
-.bn-versioning-sidebar-tab {
- background: transparent;
- border: none;
- border-bottom: 2px solid transparent;
- color: var(--bn-colors-menu-text);
- cursor: pointer;
- font-size: 13px;
- font-weight: 500;
- margin-bottom: -1px;
- opacity: 0.6;
- padding: 8px 4px;
-}
-
-.bn-versioning-sidebar-tab:hover {
- opacity: 0.85;
-}
-
-.bn-versioning-sidebar-tab[aria-selected="true"] {
- border-bottom-color: var(--bn-colors-menu-text);
- opacity: 1;
-}
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts
index e692a99add..dbf2a04141 100644
--- a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts
+++ b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts
@@ -4,12 +4,15 @@ import type { User, UserStore } from "@blocknote/core";
// version sidebar / diff tooltips would show a bare number (e.g. "1") instead
// of a name. The seed (`sampleDocument.ts`) attributes each contribution to one
// of these ids via `attribution.by`.
+// Colors are the `dark` values of BlockNote's own attribution palette
+// (`userColorPalette`). Only `color` is set, so the pale mark background is
+// derived from it (see `userMarkColors`).
export const USERS: User[] = [
- { id: "1", username: "Alice", avatarUrl: "", color: "#e6194b" },
- { id: "2", username: "Bob", avatarUrl: "", color: "#3cb44b" },
- { id: "3", username: "Carol", avatarUrl: "", color: "#f58231" },
- { id: "4", username: "Dave", avatarUrl: "", color: "#4363d8" },
- { id: "5", username: "Erin", avatarUrl: "", color: "#911eb4" },
+ { id: "1", username: "Alice", avatarUrl: "", color: "#3b3f9c" },
+ { id: "2", username: "Bob", avatarUrl: "", color: "#0f6e62" },
+ { id: "3", username: "Carol", avatarUrl: "", color: "#1e4fb0" },
+ { id: "4", username: "Dave", avatarUrl: "", color: "#6b2fa3" },
+ { id: "5", username: "Erin", avatarUrl: "", color: "#46525f" },
];
/**
diff --git a/examples/07-collaboration/14-suggestion-gallery/package.json b/examples/07-collaboration/14-suggestion-gallery/package.json
index 34aeb8f0b1..fea558e6bf 100644
--- a/examples/07-collaboration/14-suggestion-gallery/package.json
+++ b/examples/07-collaboration/14-suggestion-gallery/package.json
@@ -22,7 +22,7 @@
"react-dom": "^19.2.3",
"@blocknote/xl-multi-column": "latest",
"@y/protocols": "^1.0.6-rc.1",
- "@y/y": "^14.0.0-rc.23"
+ "@y/y": "^14.0.0-rc.26"
},
"devDependencies": {
"@types/react": "^19.2.3",
diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx
index 15a1111dac..e90c1cbf1f 100644
--- a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx
+++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx
@@ -35,8 +35,8 @@ function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness {
// Hardcoded to match the attribution-mark palette (the colors BlockNote derives
// per author id "A" / "B"), so a user's pane chrome matches their color in the
// Diff / Merged panes.
-const USER_A = { name: "User A", color: "#8a6d1a" };
-const USER_B = { name: "User B", color: "#8a2e24" };
+const USER_A = { name: "User A", color: "#46525f" };
+const USER_B = { name: "User B", color: "#8a6d1a" };
type Renderer = ReturnType;
@@ -113,14 +113,8 @@ function SuggestionsView({ scenario }: { scenario: SuggestionScenario }) {
}, []);
const authors = suggestionAuthors(scenario);
- const paneCount = 1 + authors.length + (authors.length > 1 ? 1 : 0);
return (
-
setShowSidebar(false)}
+ // Extend the row menu by composing it: the default items plus
+ // an app-specific one. Order is yours to choose.
+ snapshotMenu={
+
+
+
+
+ }
/>
)}
@@ -86,3 +87,24 @@ export default function App() {
);
}
+
+/**
+ * An application-specific row action. `useVersionSnapshot()` hands it the row
+ * it was rendered in, so it needs no props — the sidebar knows nothing about it.
+ */
+function MakeCopyItem() {
+ const { snapshot, isCurrent } = useVersionSnapshot();
+
+ return (
+ }
+ onClick={() => {
+ window.alert(
+ `Would copy ${isCurrent ? "the current version" : (snapshot.name ?? new Date(snapshot.createdAt).toLocaleString())} into a new document.`,
+ );
+ }}
+ >
+ Make a copy
+
+ );
+}
diff --git a/examples/08-extensions/02-versioning/src/sampleVersions.ts b/examples/08-extensions/02-versioning/src/sampleVersions.ts
new file mode 100644
index 0000000000..202c5af663
--- /dev/null
+++ b/examples/08-extensions/02-versioning/src/sampleVersions.ts
@@ -0,0 +1,114 @@
+import type { PartialBlock } from "@blocknote/core";
+
+export const DAY_MS = 24 * 60 * 60 * 1000;
+
+export type SampleVersion = {
+ name: string;
+ /** How long ago the version was saved. */
+ daysAgo: number;
+ blocks: PartialBlock[];
+};
+
+// Stable ids let previews show edits to the same blocks across versions.
+type SampleBlock = PartialBlock & {
+ id: string;
+ type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem";
+ content: string;
+};
+
+function updateContent(blocks: SampleBlock[], updates: Record) {
+ return blocks.map((block) => ({
+ ...block,
+ content: updates[block.id] ?? block.content,
+ }));
+}
+
+const firstDraft: SampleBlock[] = [
+ {
+ id: "title",
+ type: "heading",
+ props: { level: 2 },
+ content: "Launch plan: Notes 2.0",
+ },
+ {
+ id: "goal",
+ type: "paragraph",
+ content:
+ "Goal: ship the new editor to every workspace before the end of the quarter.",
+ },
+ {
+ id: "milestones",
+ type: "heading",
+ props: { level: 3 },
+ content: "Milestones",
+ },
+ {
+ id: "m1",
+ type: "bulletListItem",
+ content: "Beta with five design partners",
+ },
+ { id: "m3", type: "bulletListItem", content: "Public release" },
+];
+
+const addedDates = updateContent(
+ [
+ ...firstDraft.slice(0, -1),
+ {
+ id: "m2",
+ type: "bulletListItem",
+ content: "Fix the ten most-reported beta issues",
+ },
+ ...firstDraft.slice(-1),
+ ],
+ {
+ goal: "Goal: ship the new editor to every workspace before the end of September.",
+ m1: "Beta with five design partners (June)",
+ m3: "Public release (September)",
+ },
+);
+
+const marketingReview: SampleBlock[] = [
+ ...updateContent(addedDates, { m3: "Public release (September 15)" }),
+ {
+ id: "announcement",
+ type: "heading",
+ props: { level: 3 },
+ content: "Announcement",
+ },
+ {
+ id: "announcement-text",
+ type: "paragraph",
+ content:
+ "The blog post and changelog entry go out on release day. The newsletter follows a week later.",
+ },
+];
+
+/** Saved versions, oldest first. */
+export const SAMPLE_HISTORY: SampleVersion[] = [
+ { name: "First draft", daysAgo: 9, blocks: firstDraft },
+ { name: "Added dates", daysAgo: 6, blocks: addedDates },
+ { name: "Marketing review", daysAgo: 2, blocks: marketingReview },
+];
+
+/** The newest version with unsaved edits to compare against. */
+export const LIVE_DOCUMENT: PartialBlock[] = [
+ ...updateContent(marketingReview, {
+ goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.",
+ }),
+ {
+ id: "questions",
+ type: "heading",
+ props: { level: 3 },
+ content: "Open questions",
+ },
+ {
+ id: "q1",
+ type: "numberedListItem",
+ content: "Do we keep the old editor available as a fallback?",
+ },
+ {
+ id: "q2",
+ type: "numberedListItem",
+ content: "Who owns the migration guide?",
+ },
+];
diff --git a/packages/ariakit/src/components.ts b/packages/ariakit/src/components.ts
index d97a129f3f..ae9d28a63c 100644
--- a/packages/ariakit/src/components.ts
+++ b/packages/ariakit/src/components.ts
@@ -95,6 +95,9 @@ export const components: Components = {
Versioning: {
Sidebar: VersioningSidebar,
Snapshot: VersioningSnapshot,
+ // The sidebar's loader is the same dots/spinner as the suggestion menu's —
+ // one spinner per UI package, not one per feature.
+ Loader: SuggestionMenuLoader,
},
Generic: {
Badge: {
diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx
index c2a401204a..58d261fe51 100644
--- a/packages/ariakit/src/menu/Menu.tsx
+++ b/packages/ariakit/src/menu/Menu.tsx
@@ -63,8 +63,16 @@ export const MenuItem = forwardRef<
HTMLDivElement,
ComponentProps["Generic"]["Menu"]["Item"]
>((props, ref) => {
- const { className, children, icon, checked, subTrigger, onClick, ...rest } =
- props;
+ const {
+ className,
+ children,
+ icon,
+ checked,
+ disabled,
+ subTrigger,
+ onClick,
+ ...rest
+ } = props;
assertEmpty(rest);
@@ -75,6 +83,7 @@ export const MenuItem = forwardRef<
className={mergeCSSClasses("bn-ak-menu-item", className || "")}
ref={ref}
onClick={onClick}
+ disabled={disabled}
>
{icon}
{children}
@@ -88,6 +97,7 @@ export const MenuItem = forwardRef<
className={mergeCSSClasses("bn-ak-menu-item", className || "")}
ref={ref}
onClick={onClick}
+ disabled={disabled}
>
{icon}
{children}
diff --git a/packages/ariakit/src/toolbar/Toolbar.tsx b/packages/ariakit/src/toolbar/Toolbar.tsx
index 46fff5c986..b112315dd1 100644
--- a/packages/ariakit/src/toolbar/Toolbar.tsx
+++ b/packages/ariakit/src/toolbar/Toolbar.tsx
@@ -10,9 +10,11 @@ export const Toolbar = forwardRef(
(props, ref) => {
const {
className,
+ "aria-label": ariaLabel,
children,
onMouseEnter,
onMouseLeave,
+ trapFocus: _trapFocus,
variant: _variant,
...rest
} = props;
@@ -22,6 +24,7 @@ export const Toolbar = forwardRef(
return (
((props, ref) => {
- const { className, children, ...rest } = props;
-
- assertEmpty(rest, false);
-
- return (
-
- {children}
- {actions && (
- // Isolate the actions area so clicks on the menu (trigger and items,
- // which render inline rather than in a portal) don't bubble to the
- // row's select handler.
-
event.stopPropagation()}
- >
- {actions}
-
- )}
-
- );
-});
+export {
+ VersioningSidebarRoot as Sidebar,
+ VersioningSnapshotRow as Snapshot,
+} from "@blocknote/react/versioning";
diff --git a/packages/core/package.json b/packages/core/package.json
index a9a159025a..51cd2a2ba0 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -108,7 +108,7 @@
"@tiptap/pm": "^3.29.2",
"emoji-mart": "^5.6.0",
"fast-deep-equal": "^3.1.3",
- "lib0": "1.0.0-rc.22",
+ "lib0": "1.0.0-rc.32",
"prosemirror-highlight": "^0.15.3",
"prosemirror-model": "^1.25.11",
"prosemirror-state": "^1.4.4",
@@ -127,9 +127,9 @@
"yjs": "^13.6.27"
},
"peerDependencies": {
- "@y/prosemirror": "^2.0.0-6",
+ "@y/prosemirror": "^2.0.0-11",
"@y/protocols": "^1.0.6-rc.1",
- "@y/y": "^14.0.0-rc.23",
+ "@y/y": "^14.0.0-rc.26",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27"
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index ef2867121d..af62b569d4 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -81,13 +81,15 @@ NESTED BLOCKS
margin-left: 24px;
}
-.bn-block-group .bn-block-group > .bn-block-outer {
+/* Attribution wrappers use display: contents; descendant selectors also reach
+ nested blocks inside those wrappers. */
+.bn-block-group .bn-block-group .bn-block-outer {
position: relative;
}
.bn-block-group
.bn-block-group
- > .bn-block-outer:not([data-prev-depth-changed])::before {
+ .bn-block-outer:not([data-prev-depth-changed])::before {
content: " ";
display: inline;
position: absolute;
@@ -98,7 +100,7 @@ NESTED BLOCKS
.bn-block-group
.bn-block-group
- > .bn-block-outer[data-prev-depth-change="-2"]::before {
+ .bn-block-outer[data-prev-depth-change="-2"]::before {
height: 0;
}
@@ -197,11 +199,23 @@ NESTED BLOCKS
.bn-block-outer:not([data-prev-type])
> .bn-block
> .bn-block-content[data-content-type="heading"],
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > .bn-block-content[data-content-type="heading"],
.bn-block-outer:not([data-prev-type])
> .bn-block
> div[data-type="modification"]
> div[data-type="modification"]
> .bn-block-content[data-content-type="heading"],
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > [data-type="attributes"]
+ > span
+ > .bn-block-content[data-content-type="heading"],
.bn-block-outer:not([data-prev-type])
> .bn-block
> :is(ins, del)
@@ -259,6 +273,18 @@ NESTED BLOCKS
.bn-block-outer:not([data-prev-type])
> .bn-block
> .bn-block-content[data-content-type="numberedListItem"]::before,
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > .bn-block-content[data-content-type="numberedListItem"]::before,
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > [data-type="attributes"]
+ > span
+ > .bn-block-content[data-content-type="numberedListItem"]::before,
.bn-block-outer:not([data-prev-type])
> .bn-block
> div[data-type="modification"]
@@ -364,78 +390,52 @@ NESTED BLOCKS
background-color: var(--bn-colors-hovered-background);
}
-/* No list nesting */
-.bn-block-outer[data-prev-type="bulletListItem"]
- > .bn-block
- > .bn-block-content::before {
- content: "•";
+/* Keep the marker on the group so it inherits through attribution wrappers
+ between the group and its blocks. Each group resets it, so a list nested
+ under a paragraph starts with a disc again. */
+.bn-block-group {
+ --bn-bullet-marker: "•";
}
-.bn-block-outer:not([data-prev-type])
- > .bn-block
- > .bn-block-content[data-content-type="bulletListItem"]::before,
-.bn-block-outer:not([data-prev-type])
- > .bn-block
- > div[data-type="modification"]
- > .bn-block-content[data-content-type="bulletListItem"]::before,
-.bn-block-outer:not([data-prev-type])
- > .bn-block
- > :is(ins, del)
- > .bn-suggestion-node
- .bn-block-content[data-content-type="bulletListItem"]::before {
- content: "•";
+[data-content-type="bulletListItem"] ~ .bn-block-group {
+ --bn-bullet-marker: "◦";
}
-/* 1 level of list nesting */
[data-content-type="bulletListItem"]
~ .bn-block-group
- > .bn-block-outer[data-prev-type="bulletListItem"]
- > .bn-block
- > .bn-block-content::before {
- content: "◦";
+ [data-content-type="bulletListItem"]
+ ~ .bn-block-group {
+ --bn-bullet-marker: "▪\FE0E";
}
-[data-content-type="bulletListItem"]
- ~ .bn-block-group
- > .bn-block-outer:not([data-prev-type])
+.bn-block-outer[data-prev-type="bulletListItem"]
> .bn-block
- > .bn-block-content[data-content-type="bulletListItem"]::before,
-[data-content-type="bulletListItem"]
- ~ .bn-block-group
- > .bn-block-outer:not([data-prev-type])
+ > .bn-block-content::before,
+.bn-block-outer:not([data-prev-type])
> .bn-block
- > div[data-type="modification"]
- > .bn-block-content[data-content-type="bulletListItem"]::before {
- content: "◦";
-}
-
-/* 2 levels of list nesting */
-[data-content-type="bulletListItem"]
- ~ .bn-block-group
- [data-content-type="bulletListItem"]
- ~ .bn-block-group
- > .bn-block-outer[data-prev-type="bulletListItem"]
+ > .bn-block-content[data-content-type="bulletListItem"]::before,
+.bn-block-outer:not([data-prev-type])
> .bn-block
- > .bn-block-content::before {
- content: "▪\FE0E";
-}
-
-[data-content-type="bulletListItem"]
- ~ .bn-block-group
- [data-content-type="bulletListItem"]
- ~ .bn-block-group
- > .bn-block-outer:not([data-prev-type])
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > :is(ins, del, [data-type="attributes"])
+ > span
+ > .bn-block-content[data-content-type="bulletListItem"]::before,
+.bn-block-outer:not([data-prev-type])
> .bn-block
+ > [data-type="attributes"]
+ > span
> .bn-block-content[data-content-type="bulletListItem"]::before,
-[data-content-type="bulletListItem"]
- ~ .bn-block-group
- [data-content-type="bulletListItem"]
- ~ .bn-block-group
- > .bn-block-outer:not([data-prev-type])
+.bn-block-outer:not([data-prev-type])
> .bn-block
> div[data-type="modification"]
- > .bn-block-content[data-content-type="bulletListItem"]::before {
- content: "▪\FE0E";
+ > .bn-block-content[data-content-type="bulletListItem"]::before,
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > :is(ins, del)
+ > .bn-suggestion-node
+ .bn-block-content[data-content-type="bulletListItem"]::before {
+ content: var(--bn-bullet-marker);
}
/* CODE BLOCKS */
@@ -1090,9 +1090,15 @@ div[data-type="modification"] {
border-radius: 4px;
}
+/* Lift attribution fills above the dark editor surface. Mixing the already
+ dark author color with black made short insertions nearly invisible. */
.dark.bn-root ins,
.dark.bn-root del {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
color: var(--user-color-light);
}
@@ -1111,7 +1117,11 @@ serialized/static output, where the wrapper is a real, painted box.
}
.dark.bn-root .bn-suggestion-mark {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
color: white;
}
@@ -1132,7 +1142,11 @@ row/cell). Block deletions that *do* wrap blocks are restyled per-block below.
}
.dark.bn-root .bn-suggestion-node > * {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
}
/*
@@ -1149,11 +1163,12 @@ that wrap blocks get a per-block badge below instead.
display: inline-block;
margin-right: 6px;
padding: 0 4px;
+ /* Matches the block-level badge below, so the two read as one label style. */
font-size: 11px;
- font-weight: bold;
+ font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
- line-height: 1.4;
+ line-height: 16px;
vertical-align: middle;
/* Use the editor's text color (themed for light/dark) rather than inheriting,
which would pick up the deleted content's user color. */
@@ -1202,8 +1217,8 @@ hence the descendant match.
Deleted table cells are a special case: a
/
has no `.bn-block-content` and
its text sits in a bare
(not `.bn-inline-content`), so neither the
strikethrough above nor the block card reaches it — and a table row/cell can't
-host the "Deleted" card anyway. Treat them like inline deletions instead: strike
-the cell text through in the author's color, and suppress the fallback badge.
+host the "Deleted" card anyway. Strike the cell text through in the author's
+color and suppress badges inside cells, including nested paragraph badges.
*/
.bn-suggestion-node--delete :is(td, th) p {
color: var(--user-color-dark);
@@ -1214,7 +1229,8 @@ the cell text through in the author's color, and suppress the fallback badge.
color: var(--user-color-light);
}
-.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before {
+.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before,
+:is(td, th) .bn-suggestion-node--delete > :first-child::before {
content: none;
}
@@ -1227,16 +1243,34 @@ spans the whole subtree; the media wrapper exists only for files), but it sets
only non-collapsing properties — background / radius / padding never depend on the
content's intrinsic size, so no block can break.
*/
+/* Attribute changes use the same card for text blocks as for media blocks. */
+[data-type="attributes"] > .bn-suggestion-node > .bn-block-content,
.bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) {
+ /* The card bleeds this far into the gutters on both sides, so tinting a block
+ never shifts its content sideways. */
+ --bn-suggestion-card-inset: 6px;
background-color: color-mix(in srgb, var(--user-color-light) 50%, white);
- border-radius: 16px;
- padding: 12px;
+ border-radius: 6px;
+ /* A hairline in the author's color, so a pale tint still reads as a card. */
+ box-shadow: 0 0 0 1px
+ color-mix(in srgb, var(--user-color-dark) 25%, transparent);
+ padding: 3px var(--bn-suggestion-card-inset);
+ margin-left: calc(-1 * var(--bn-suggestion-card-inset));
+ width: calc(100% + 2 * var(--bn-suggestion-card-inset));
}
+.dark.bn-root
+ [data-type="attributes"]
+ > .bn-suggestion-node
+ > .bn-block-content,
.dark.bn-root
.bn-suggestion-node
.bn-block-content:not(:has(.bn-inline-content)) {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
}
/*
@@ -1256,11 +1290,12 @@ gated on a wrapper that only width-bearing blocks have.
/*
A deletion additionally flags the block with the localized "Deleted" label, placed
-above the content (out of flow) with extra top padding reserving its row.
+above the content (out of flow) with extra top padding reserving its row: the
+card's own 3px, the label's 16px line box, and 2px of breathing room under it.
*/
.bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) {
position: relative;
- padding: 48px 24px 24px;
+ padding-top: calc(3px + 16px + 2px);
}
.bn-suggestion-node--delete
@@ -1269,11 +1304,13 @@ above the content (out of flow) with extra top padding reserving its row.
/* Sits in the reserved top padding, above the content. Out of flow so it never
becomes a flex item beside the block. */
position: absolute;
- top: 16px;
- left: 24px;
- font-size: 18px;
- font-weight: 500;
- line-height: 1.2;
+ top: 3px;
+ left: var(--bn-suggestion-card-inset);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
/* Use the editor's text color (themed for light/dark) rather than inheriting,
which would pick up the suggestion's user color. */
color: var(--bn-colors-editor-text);
@@ -1311,7 +1348,11 @@ left untouched so only the dotted underline carries the color. Both the inline
.dark.bn-root [data-type="modification"] .bn-suggestion-mark:hover,
.dark.bn-root [data-type="modification"] .bn-suggestion-node:hover > * {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
}
/*
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 25b93d03f4..9746010d8b 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -101,7 +101,7 @@ export interface BlockNoteEditorOptions<
dictionary?: Dictionary & Record;
/**
- * Disable internal extensions (based on keys / extension name)
+ * Disable internal extensions (based on keys / extension name).
*
* @note Advanced
*/
@@ -498,6 +498,8 @@ export class BlockNoteEditor<
const tiptapOptions: EditorOptions = {
...blockNoteTipTapOptions,
...newOptions._tiptapOptions,
+ // ReadOnlyExtension owns editability, including the initial application preference.
+ editable: true,
element: null,
autofocus: newOptions.autofocus ?? false,
extensions: tiptapExtensions,
@@ -1039,7 +1041,10 @@ export class BlockNoteEditor<
}
/**
- * Makes the editor editable or locks it, depending on the argument passed.
+ * Sets the application's editable preference. Feature read-only restrictions
+ * still apply when set to true.
+ * Plugins can temporarily prevent editing without changing this setting.
+ * The getter reports whether editing is currently allowed by both.
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css
index a1a3dda7b0..748073c66a 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -195,3 +195,64 @@ For the ShowSelectionPlugin
background-color: highlight;
padding: 2px 0;
}
+
+/* Shared loading indicator. Override the color and size on the host element. */
+.bn-loader,
+.bn-editor.bn-loading::before {
+ animation:
+ bn-loader-rotate 1s linear infinite,
+ bn-loader-clip 2s linear infinite;
+ border: calc(5 * var(--bn-loader-size, 1px)) solid
+ var(--bn-loader-color, currentColor);
+ border-radius: 50%;
+ box-sizing: border-box;
+ display: block;
+ height: calc(48 * var(--bn-loader-size, 1px));
+ width: calc(48 * var(--bn-loader-size, 1px));
+}
+
+/* Keep the editor's loader visible while scrolling without moving content. */
+.bn-editor.bn-loading > .bn-block-group {
+ opacity: 0.4;
+ transition: opacity 0.2s ease;
+}
+
+.bn-editor.bn-loading::before {
+ content: "";
+ margin: 0 auto calc(-48 * var(--bn-loader-size, 1px));
+ position: sticky;
+ top: 16px;
+ z-index: 1;
+}
+
+@keyframes bn-loader-rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes bn-loader-clip {
+ 0% {
+ clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0);
+ }
+ 25% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0);
+ }
+ 50% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%);
+ }
+ 75% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
+ }
+ 100% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bn-loader,
+ .bn-editor.bn-loading::before {
+ animation: none;
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
+ }
+}
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 853cca2493..0eb62d9e7d 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -21,6 +21,7 @@ import {
PlaceholderExtension,
PositionMappingExtension,
PreviousBlockTypeExtension,
+ ReadOnlyExtension,
ShowSelectionExtension,
SideMenuExtension,
SourceBlockWithPreviewExtension,
@@ -168,6 +169,7 @@ export function getDefaultExtensions(
LinkToolbarExtension(options),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
+ ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
ShowSelectionExtension(options),
SideMenuExtension(options),
SourceBlockWithPreviewExtension(),
diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts
index 9dc3eebff2..c6a2edbdcb 100644
--- a/packages/core/src/editor/managers/StateManager.ts
+++ b/packages/core/src/editor/managers/StateManager.ts
@@ -1,4 +1,5 @@
import { Command, Transaction } from "prosemirror-state";
+import { ReadOnlyExtension } from "../../extensions/ReadOnly/ReadOnly.js";
import type { HistoryExtension } from "../../extensions/History/History.js";
import { BlockNoteEditor } from "../BlockNoteEditor.js";
@@ -188,13 +189,24 @@ export class StateManager {
}
return false;
}
+ if (this.editor.headless) {
+ // No live view while unmounted, so tiptap can't consult plugin props
+ // (its unmounted view stub reports editable: true). Mirror the
+ // ReadOnly plugin's `editable` prop directly so the application
+ // preference and feature restrictions still read back correctly,
+ // e.g. for static/server-side rendering via block render functions.
+ const state = this.editor.getExtension(ReadOnlyExtension)?.store.state;
+ if (state) {
+ return state.isEditable && state.enabledSet.size === 0;
+ }
+ }
return this.editor._tiptapEditor.isEditable === undefined
? true
: this.editor._tiptapEditor.isEditable;
}
/**
- * Makes the editor editable or locks it, depending on the argument passed.
+ * Sets the application's editable preference without releasing feature restrictions.
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
@@ -205,9 +217,7 @@ export class StateManager {
// not relevant on headless
return;
}
- if (this.editor._tiptapEditor.options.editable !== editable) {
- this.editor._tiptapEditor.setEditable(editable);
- }
+ this.editor.getExtension(ReadOnlyExtension)!.setEditable(editable);
}
/**
diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts
new file mode 100644
index 0000000000..6cb09d0592
--- /dev/null
+++ b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts
@@ -0,0 +1,181 @@
+/** @vitest-environment jsdom */
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vite-plus/test";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { ReadOnlyExtension } from "./ReadOnly.js";
+
+describe("ReadOnlyExtension", () => {
+ let editor: BlockNoteEditor;
+ let readOnly: ReturnType>;
+
+ beforeEach(() => {
+ editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+ readOnly = editor.getExtension(ReadOnlyExtension)!;
+ });
+
+ afterEach(() => editor.unmount());
+
+ it("keeps editing disabled until every feature releases its restriction", () => {
+ readOnly.setReadOnly(true, "preview");
+ readOnly.setReadOnly(true, "upload");
+ readOnly.setReadOnly(true, "upload");
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "preview");
+ readOnly.setReadOnly(false, "unrelated");
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("preserves the application's latest editable setting", () => {
+ readOnly.setReadOnly(true, "preview");
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = false;
+ readOnly.setReadOnly(false, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("notifies transaction subscribers without reporting document changes", () => {
+ const changes = vi.fn();
+ const editableStates: boolean[] = [];
+ editor.onChange(changes);
+ editor._tiptapEditor.on("transaction", () => {
+ editableStates.push(editor.isEditable);
+ });
+
+ readOnly.setReadOnly(true, "preview");
+ expect(editableStates.length).toBeGreaterThan(0);
+ expect(editableStates.every((editable) => !editable)).toBe(true);
+ editableStates.length = 0;
+ readOnly.setReadOnly(true, "preview");
+ expect(editableStates).toEqual([]);
+ readOnly.setReadOnly(false, "preview");
+
+ expect(editableStates.length).toBeGreaterThan(0);
+ expect(editableStates.every((editable) => editable)).toBe(true);
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it("uses editable metadata for both inputs and skips changes that keep editing locked", () => {
+ const metadata: unknown[] = [];
+ const changes = vi.fn();
+ editor.onChange(changes);
+ editor._tiptapEditor.on("transaction", ({ transaction }) => {
+ metadata.push(transaction.getMeta("editable"));
+ });
+
+ editor.isEditable = false;
+ expect(metadata.filter((value) => value !== undefined)).toEqual([true]);
+ metadata.length = 0;
+ readOnly.setReadOnly(true, "preview");
+ editor.isEditable = true;
+ readOnly.setReadOnly(true, "upload");
+ readOnly.setReadOnly(false, "preview");
+ expect(metadata).toEqual([]);
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(metadata.filter((value) => value !== undefined)).toEqual([true]);
+ expect(editor.isEditable).toBe(true);
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it("applies initial editability and preserves it across remounts", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } });
+ editor.mount(document.createElement("div"));
+ expect(editor.isEditable).toBe(false);
+ expect(editor.prosemirrorView.editable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ editor.isEditable = false;
+ editor.unmount();
+ editor.mount(document.createElement("div"));
+ expect(editor.prosemirrorView.editable).toBe(false);
+ });
+
+ it("reports application and feature editability while unmounted", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create();
+ readOnly = editor.getExtension(ReadOnlyExtension)!;
+
+ expect(editor.isEditable).toBe(true);
+
+ editor.isEditable = false;
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+
+ readOnly.setReadOnly(true, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = false;
+ readOnly.setReadOnly(false, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("honours editability set before mount", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create();
+ editor.isEditable = false;
+ expect(editor.isEditable).toBe(false);
+ editor.mount(document.createElement("div"));
+ expect(editor.isEditable).toBe(false);
+ expect(editor.prosemirrorView.editable).toBe(false);
+ });
+
+ it("groups application editability changes into the pending transaction", () => {
+ const transactions = vi.fn();
+ const changes = vi.fn();
+ editor._tiptapEditor.on("transaction", transactions);
+ editor.onChange(changes);
+
+ editor.transact(() => {
+ editor.isEditable = false;
+ });
+ expect(editor.isEditable).toBe(false);
+ expect(transactions).toHaveBeenCalled();
+ expect(changes).not.toHaveBeenCalled();
+
+ transactions.mockClear();
+ editor.transact((tr) => {
+ tr.insertText("hello", 1);
+ editor.isEditable = true;
+ });
+ expect(editor.isEditable).toBe(true);
+ expect(editor.prosemirrorState.doc.textContent).toContain("hello");
+ expect(transactions).toHaveBeenCalled();
+ expect(changes).toHaveBeenCalledTimes(1);
+ });
+
+ it("composes with pending document and metadata-only transactions", () => {
+ editor.transact((tr) => {
+ tr.insertText("hello", 1);
+ readOnly.setReadOnly(true, "preview");
+ });
+ expect(editor.prosemirrorState.doc.textContent).toContain("hello");
+ expect(editor.isEditable).toBe(false);
+
+ editor.transact(() => readOnly.setReadOnly(false, "preview"));
+ expect(editor.isEditable).toBe(true);
+ });
+});
diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts
new file mode 100644
index 0000000000..a15876bbb4
--- /dev/null
+++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts
@@ -0,0 +1,73 @@
+import { Plugin, PluginKey } from "prosemirror-state";
+import {
+ createExtension,
+ createStore,
+ type ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+
+const PLUGIN_KEY = new PluginKey("bn-read-only");
+
+/** Owns application editability and independent feature restrictions. */
+export const ReadOnlyExtension = createExtension(
+ ({
+ editor,
+ options,
+ }: ExtensionOptions<{ editable?: boolean } | undefined>) => {
+ const store = createStore(
+ {
+ isEditable: options?.editable ?? true,
+ enabledSet: new Set(),
+ },
+ {
+ onUpdate(state, prevState) {
+ if (
+ (state.isEditable && state.enabledSet.size === 0) ===
+ (prevState.isEditable && prevState.enabledSet.size === 0)
+ ) {
+ return;
+ }
+ if (!editor.headless) {
+ // Recompute plugin editability and notify UI subscribers without a
+ // document change. Reuse any transaction already in progress.
+ editor.transact((tr) => tr.setMeta("editable", true));
+ }
+ },
+ },
+ );
+
+ return {
+ key: "readOnly",
+ store,
+ prosemirrorPlugins: [
+ new Plugin({
+ key: PLUGIN_KEY,
+ props: {
+ editable: () =>
+ store.state.isEditable && store.state.enabledSet.size === 0,
+ },
+ }),
+ ],
+ /** Set the application's preference without releasing feature restrictions. */
+ setEditable(editable: boolean) {
+ if (store.state.isEditable === editable) {
+ return;
+ }
+ store.setState({ ...store.state, isEditable: editable });
+ },
+ /**
+ * Enable or disable read-only mode for a feature identified by key.
+ * Passing false releases only that feature's restriction; other features
+ * and the application's editor.isEditable setting still apply.
+ * Repeated calls with the same key are idempotent.
+ */
+ setReadOnly(readOnly: boolean, key: string) {
+ store.setState({
+ ...store.state,
+ enabledSet: readOnly
+ ? new Set([...store.state.enabledSet, key])
+ : new Set([...store.state.enabledSet].filter((k) => k !== key)),
+ });
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/extensions/Versioning/Versioning.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts
index 158c152da4..16d6f7d41c 100644
--- a/packages/core/src/extensions/Versioning/Versioning.test.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.test.ts
@@ -6,14 +6,28 @@ import {
beforeEach,
describe,
expect,
+ expectTypeOf,
it,
vi,
} from "vite-plus/test";
+import type { Block } from "../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { UserStoreOrResolver } from "../../user/index.js";
-import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js";
-import type { VersionSnapshot } from "./Versioning.js";
+import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+import { SCROLL_TO_FIRST_CHANGE_DELAY_MS } from "./scrollToFirstChange.js";
+import {
+ LOADING_PREVIEW_CLASS,
+ LOADING_PREVIEW_DELAY_MS,
+ VersioningExtension,
+} from "./Versioning.js";
+import type {
+ PreviewController,
+ VersioningEndpoints,
+ VersioningExtensionOptions,
+ VersioningState,
+ VersionSnapshot,
+} from "./Versioning.js";
import {
createInMemoryPreviewController,
createInMemoryVersioningEndpoints,
@@ -23,11 +37,30 @@ import {
// Helpers
// ---------------------------------------------------------------------------
-function createEditor() {
- const editor = BlockNoteEditor.create();
- const div = document.createElement("div");
- editor.mount(div);
- return editor;
+/**
+ * A mounted editor with a `VersioningExtension` registered on it — registered
+ * rather than built alongside, so the extension's own ProseMirror plugins (the
+ * read-only-while-held one) are installed. `build` receives the editor,
+ * for options that need to close over it.
+ */
+function setupWith(
+ build: (
+ editor: BlockNoteEditor,
+ ) => Pick &
+ Partial>,
+) {
+ const editor = BlockNoteEditor.create({
+ extensions: [
+ (ctx) =>
+ VersioningExtension({
+ preview: createInMemoryPreviewController(ctx.editor),
+ getCurrentDocument: () => ctx.editor.document,
+ ...build(ctx.editor),
+ })(ctx),
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ return { editor, ext: editor.getExtension(VersioningExtension)! };
}
function getEditorText(editor: BlockNoteEditor): string {
@@ -38,19 +71,50 @@ function setEditorText(editor: BlockNoteEditor, text: string) {
editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);
}
-/** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */
+/** Resolve or reject a request at an explicit point in a loading transition. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+/** Minimal version factory for versioning tests. */
function snap(
id: string,
createdAt: number,
extra?: Partial,
): VersionSnapshot {
- return { id, createdAt, updatedAt: createdAt, ...extra };
+ return { id, createdAt, ...extra };
+}
+
+/** A rest state, with the in-flight flags clear. */
+function state(overrides?: Partial): VersioningState {
+ return {
+ list: { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ ...overrides,
+ };
+}
+
+/** The loaded list, or a failure — every test that reads it has listed first. */
+function loadedList(ext: { store: { state: VersioningState } }) {
+ const { list } = ext.store.state;
+ if (!list.loaded) {
+ throw new Error("expected the version list to be loaded");
+ }
+ return list;
}
/**
* Wire up a real editor with the in-memory versioning adapter.
*
- * Returns the extension instance, the editor, and helpers to seed snapshots
+ * Returns the extension instance, the editor, and helpers to seed versions
* directly into the backend (bypassing the extension).
*/
function setup(opts?: {
@@ -58,28 +122,42 @@ function setup(opts?: {
withoutRestore?: boolean;
withoutUpdateName?: boolean;
resolveUsers?: UserStoreOrResolver;
+ scrollToFirstChange?: boolean;
}) {
- const editor = createEditor();
- setEditorText(editor, opts?.initialText ?? "initial doc");
-
const endpoints = createInMemoryVersioningEndpoints();
- const preview = createInMemoryPreviewController(editor);
-
if (opts?.withoutRestore) {
- (endpoints as any).restore = undefined;
+ endpoints.restore = undefined;
}
if (opts?.withoutUpdateName) {
- (endpoints as any).rename = undefined;
+ endpoints.rename = undefined;
}
- const ext = VersioningExtension({
- endpoints,
- preview,
- getCurrentDocument: () => editor.document,
- resolveUsers: opts?.resolveUsers,
- })({ editor });
+ // Registered on the editor rather than built beside it, so the extension's
+ // own ProseMirror plugins (the read-only-while-held one) are installed.
+ let preview!: ReturnType;
+ const editor = BlockNoteEditor.create({
+ extensions: [
+ (ctx) => {
+ preview = createInMemoryPreviewController(ctx.editor);
+ return VersioningExtension({
+ endpoints,
+ preview,
+ // Through the controller, as the real adapter does: while previewing,
+ // `editor.document` holds the previewed version, not the live one.
+ getCurrentDocument: () => preview.getLiveDocument(),
+ serializeCurrentContent: () => preview.getLiveDocument(),
+ resolveUsers: opts?.resolveUsers,
+ scrollToFirstChange: opts?.scrollToFirstChange,
+ })(ctx);
+ },
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ setEditorText(editor, opts?.initialText ?? "initial doc");
+
+ const ext = editor.getExtension(VersioningExtension)!;
- /** Seed a snapshot into the backend by capturing the current editor doc. */
+ /** Seed a version into the backend by capturing the current editor doc. */
const seed = async (text: string, name?: string) => {
// Temporarily set editor text, create via endpoints, then restore.
const savedBlocks = editor.document;
@@ -88,8 +166,8 @@ function setup(opts?: {
const snapshot = await endpoints.create!(blocks, { name });
// Restore original text.
editor.replaceBlocks(editor.document, savedBlocks);
- // Refresh the store so the extension can resolve the seeded snapshot by id
- // (preview/restore look snapshots up in the store, as the UI would after
+ // Refresh the store so the extension can resolve the seeded version by id
+ // (preview/restore look versions up in the store, as the UI would after
// listing).
await ext.list();
return snapshot;
@@ -102,15 +180,13 @@ function setup(opts?: {
// Tests
// ---------------------------------------------------------------------------
-describe("sortSnapshotsNewestFirst", () => {
- it("sorts newest-first by createdAt", () => {
- const input = [snap("a", 100), snap("b", 300), snap("c", 200)];
- const sorted = sortSnapshotsNewestFirst(input);
- expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]);
+describe("VersioningExtension", () => {
+ it("requires preview controllers to render synchronously", () => {
+ expectTypeOf<() => Promise>().not.toExtend<
+ PreviewController["enterPreview"]
+ >();
});
-});
-describe("VersioningExtension", () => {
let ctx: ReturnType;
beforeEach(() => {
@@ -122,137 +198,438 @@ describe("VersioningExtension", () => {
});
// -------------------------------------------------------------------------
- // Listing snapshots
+ // Loading state
// -------------------------------------------------------------------------
- describe("listing snapshots", () => {
+ describe("getLoadingState", () => {
+ it("is idle when neither operation is in flight", () => {
+ expect(ctx.ext.getLoadingState(state())).toEqual({ type: "idle" });
+ });
+
+ it("reports listing when only the list is fetching", () => {
+ expect(ctx.ext.getLoadingState(state({ listing: true }))).toEqual({
+ type: "listing",
+ });
+ });
+
+ it("reports loading-preview while a preview loads, outranking listing", () => {
+ const view = { mode: "snapshot", snapshotId: "a" } as const;
+ const previewing = { type: "loading-preview", view } as const;
+ expect(ctx.ext.getLoadingState(state({ loadingView: view }))).toEqual(
+ previewing,
+ );
+ expect(
+ ctx.ext.getLoadingState(state({ listing: true, loadingView: view })),
+ ).toEqual(previewing);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Listing versions
+ // -------------------------------------------------------------------------
+
+ describe("listing versions", () => {
+ it("starts unloaded and live", () => {
+ expect(ctx.ext.store.state.list).toEqual({ loaded: false });
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.ext.getLoadingState()).toEqual({ type: "idle" });
+ });
+
it("populates the store from the backend, sorted newest-first", async () => {
vi.useFakeTimers();
- // Seed snapshots with distinct timestamps directly via endpoints.
- await ctx.endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "v1" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
vi.advanceTimersByTime(1000);
- await ctx.endpoints.create!([
- {
- id: "2",
- type: "paragraph" as const,
- content: "v2" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
vi.advanceTimersByTime(1000);
- await ctx.endpoints.create!([
- {
- id: "3",
- type: "paragraph" as const,
- content: "v3" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
const result = await ctx.ext.list();
- expect(result).toHaveLength(3);
- // Newest first: v3, v2, v1
- expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt);
- expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt);
- expect(ctx.ext.store.state.snapshots).toEqual(result);
+ expect(result.snapshots).toHaveLength(3);
+ expect(result.snapshots[0]!.createdAt).toBeGreaterThan(
+ result.snapshots[1]!.createdAt,
+ );
+ expect(result.snapshots[1]!.createdAt).toBeGreaterThan(
+ result.snapshots[2]!.createdAt,
+ );
+ expect(result.current).toBeDefined();
+ expect(ctx.ext.store.state.list).toEqual(result);
vi.useRealTimers();
});
+ it("never touches the view", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+
+ await ctx.ext.list();
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+ });
+
it("reflects backend changes on subsequent calls", async () => {
- expect(await ctx.ext.list()).toEqual([]);
-
- await ctx.endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "external" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ expect((await ctx.ext.list()).snapshots).toEqual([]);
+
+ await ctx.endpoints.create!([], {});
- const after = await ctx.ext.list();
- expect(after).toHaveLength(1);
+ expect((await ctx.ext.list()).snapshots).toHaveLength(1);
});
});
// -------------------------------------------------------------------------
- // Creating snapshots
+ // Editability
// -------------------------------------------------------------------------
- describe("creating snapshots", () => {
- it("captures the current state and adds the snapshot to the store", async () => {
+ describe("editability", () => {
+ it("is read-only while previewing and editable again on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ expect(ctx.editor.isEditable).toBe(true);
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("stays read-only across preview switches", async () => {
+ const s1 = await ctx.seed("content s1");
+ const s2 = await ctx.seed("content s2");
+
+ await ctx.ext.previewSnapshot(s1.id);
+ await ctx.ext.previewSnapshot(s2.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("ignores an `isEditable` set while previewing, and honours it on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ // What a React re-render does: re-applies the host's `editable` prop.
+ ctx.editor.isEditable = true;
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("preserves a host change to read-only made during preview", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ ctx.editor.isEditable = false;
+ ctx.ext.exitPreview();
+
+ expect(ctx.editor.isEditable).toBe(false);
+ ctx.editor.isEditable = true;
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("leaves another feature's read-only restriction in place on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ const readOnly = ctx.editor.getExtension(ReadOnlyExtension)!;
+ await ctx.ext.previewSnapshot(seeded.id);
+ readOnly.setReadOnly(true, "upload");
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("leaves a read-only editor read-only", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ ctx.editor.isEditable = false;
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(false);
+ });
+
+ it("can be changed from inside a transaction", async () => {
+ // A dispatch of its own in here would leave the pending transaction
+ // built on a stale state; the change has to ride along with it instead.
+ ctx.editor.transact((tr) => {
+ tr.insertText("!", 1);
+ ctx.editor.isEditable = false;
+ });
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("!initial doc");
+
+ ctx.editor.transact(() => {
+ ctx.editor.isEditable = true;
+ });
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("does not report a document change for the editability change", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ let changes = 0;
+ ctx.editor.onChange(() => changes++);
+ ctx.ext.exitPreview();
+
+ // Exiting restores the live document through the preview controller,
+ // which is one change; becoming editable again is not another.
+ expect(changes).toBe(1);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Status
+ // -------------------------------------------------------------------------
+
+ describe("status", () => {
+ it.each(["resolve", "reject"] as const)(
+ "keeps the latest preview busy when an older request completes via %s",
+ async (outcome) => {
+ const first = await ctx.seed("first content");
+ const second = await ctx.seed("second content");
+ const firstRequest = deferred();
+ const secondRequest = deferred();
+ vi.spyOn(ctx.endpoints, "getContent")
+ .mockReturnValueOnce(firstRequest.promise)
+ .mockReturnValueOnce(secondRequest.promise);
+
+ vi.useFakeTimers();
+ try {
+ const older = ctx.ext.previewSnapshot(first.id);
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ const newer = ctx.ext.previewSnapshot(second.id);
+ const latestView = {
+ mode: "snapshot",
+ snapshotId: second.id,
+ compareToId: undefined,
+ };
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(true);
+
+ if (outcome === "reject") {
+ const failure = expect(older).rejects.toThrow("old request failed");
+ firstRequest.reject(new Error("old request failed"));
+ await failure;
+ } else {
+ firstRequest.resolve(ctx.editor.document);
+ await older;
+ }
+ expect(ctx.ext.store.state.view).toEqual(latestView);
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "loading-preview",
+ view: latestView,
+ });
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(true);
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("initial doc");
+
+ secondRequest.resolve([]);
+ await newer;
+ expect(ctx.ext.store.state.view).toEqual(latestView);
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "idle",
+ });
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("");
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+
+ it.each(["content", "baseline", "attributions"] as const)(
+ "stays busy until comparison %s finishes",
+ async (stage) => {
+ const gate = deferred();
+ const current = snap("current", 30);
+ const shown = snap("shown", 20);
+ const baseline = snap("baseline", 10);
+ const enterPreview = vi.fn(() => undefined);
+ const { editor, ext } = setupWith(() => ({
+ endpoints: {
+ list: async () => ({ current, snapshots: [shown, baseline] }),
+ getContent: async (snapshot) => {
+ if (
+ (stage === "content" && snapshot.id === shown.id) ||
+ (stage === "baseline" && snapshot.id === baseline.id)
+ ) {
+ await gate.promise;
+ }
+ return [];
+ },
+ getAttributions: async () => {
+ if (stage === "attributions") {
+ await gate.promise;
+ }
+ return undefined;
+ },
+ },
+ preview: { enterPreview, exitPreview: () => {} },
+ }));
+ try {
+ await ext.list();
+ const pending = ext.previewSnapshot(shown.id, {
+ compareTo: baseline.id,
+ });
+ expect(ext.getLoadingState()).toEqual({
+ type: "loading-preview",
+ view: {
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: baseline.id,
+ },
+ });
+ expect(editor.isEditable).toBe(false);
+ expect(enterPreview).not.toHaveBeenCalled();
+
+ gate.resolve();
+ await pending;
+ expect(enterPreview).toHaveBeenCalledOnce();
+ expect(ext.getLoadingState()).toEqual({ type: "idle" });
+ expect(ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: baseline.id,
+ });
+ expect(editor.isEditable).toBe(false);
+ } finally {
+ gate.resolve();
+ editor.unmount();
+ }
+ },
+ );
+
+ it.each([0, LOADING_PREVIEW_DELAY_MS])(
+ "clears pending preview loading on exit after %i ms",
+ async (elapsed) => {
+ const seeded = await ctx.seed("old content");
+ const { promise: gate, resolve: release } = deferred();
+ const getContent = ctx.endpoints.getContent;
+ ctx.endpoints.getContent = async (snapshot) => {
+ await gate;
+ return getContent(snapshot);
+ };
+
+ vi.useFakeTimers();
+ try {
+ const pending = ctx.ext.previewSnapshot(seeded.id);
+ vi.advanceTimersByTime(elapsed);
+ ctx.ext.exitPreview();
+
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "idle",
+ });
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+ // Exiting before the delay must also cancel the scheduled indicator.
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+
+ release();
+ await pending;
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(getEditorText(ctx.editor)).toBe("initial doc");
+ } finally {
+ release();
+ vi.useRealTimers();
+ }
+ },
+ );
+ });
+
+ // -------------------------------------------------------------------------
+ // Naming the current version
+ // -------------------------------------------------------------------------
+
+ describe("naming the current version", () => {
+ it("captures the current state and re-lists", async () => {
setEditorText(ctx.editor, "my document content");
const snapshot = await ctx.ext.create!({ name: "Draft 1" });
expect(snapshot.name).toBe("Draft 1");
- expect(snapshot.id).toBeDefined();
- expect(ctx.ext.store.state.snapshots).toHaveLength(1);
+ expect(loadedList(ctx.ext).snapshots).toHaveLength(1);
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBe("Draft 1");
- // The snapshot content should round-trip — verify by previewing.
+ // The version content should round-trip — verify by previewing.
await ctx.ext.previewSnapshot(snapshot.id);
expect(getEditorText(ctx.editor)).toBe("my document content");
});
- it("maintains newest-first order when adding to existing snapshots", async () => {
+ it("maintains newest-first order", async () => {
vi.useFakeTimers();
- // Seed an older snapshot.
const old = await ctx.seed("old content", "Old");
vi.advanceTimersByTime(1000);
- // List so the store knows about the seeded snapshot.
- await ctx.ext.list();
-
const newer = await ctx.ext.create!({ name: "Newer" });
- expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id);
- expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id);
+ expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(newer.id);
+ expect(loadedList(ctx.ext).snapshots[1]!.id).toBe(old.id);
vi.useRealTimers();
});
});
// -------------------------------------------------------------------------
- // Previewing snapshots
+ // Previewing
// -------------------------------------------------------------------------
- describe("previewing snapshots", () => {
- it("shows a snapshot and tracks it in the store", async () => {
- const snap = await ctx.seed("snapshot content");
+ describe("previewing versions", () => {
+ it("shows a version and tracks it in the view", async () => {
+ const seeded = await ctx.seed("snapshot content");
- await ctx.ext.previewSnapshot(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
- expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
expect(getEditorText(ctx.editor)).toBe("snapshot content");
});
- it("supports comparing against an older snapshot", async () => {
- const _v1 = await ctx.seed("content v1");
+ it("supports comparing against an older version", async () => {
+ const v1 = await ctx.seed("content v1");
const v2 = await ctx.seed("content v2");
// The in-memory preview controller doesn't render diffs, but the call
- // should succeed and show the primary snapshot content.
- await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id });
-
+ // should succeed and show the primary version's content.
+ await ctx.ext.previewSnapshot(v2.id, { compareTo: v1.id });
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: v2.id,
+ compareToId: v1.id,
+ });
expect(getEditorText(ctx.editor)).toBe("content v2");
});
- it("switching previews updates to the new snapshot", async () => {
+ it("switching previews updates to the new version", async () => {
const s1 = await ctx.seed("content s1");
const s2 = await ctx.seed("content s2");
@@ -260,11 +637,118 @@ describe("VersioningExtension", () => {
expect(getEditorText(ctx.editor)).toBe("content s1");
await ctx.ext.previewSnapshot(s2.id);
- expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: s2.id,
+ compareToId: undefined,
+ });
expect(getEditorText(ctx.editor)).toBe("content s2");
});
});
+ // -------------------------------------------------------------------------
+ // Scroll to first change
+ // -------------------------------------------------------------------------
+
+ describe("scroll to first change", () => {
+ /**
+ * A preview controller that stamps one attribution mark into the editor
+ * DOM, the way the real diff renderer does. It has to happen inside
+ * `enterPreview`: locking editability redraws the ProseMirror view, which
+ * strips any foreign node put there beforehand.
+ */
+ function setupScrollProbe(opts?: { scroll?: boolean; withMark?: boolean }) {
+ const { editor, ext } = setupWith((editor) => ({
+ endpoints: {
+ list: async () => ({
+ current: snap("current", 30),
+ snapshots: [snap("a", 10), snap("b", 5)],
+ }),
+ getContent: async () => [],
+ getAttributions: async () => undefined,
+ } satisfies VersioningEndpoints,
+ preview: {
+ enterPreview: () => {
+ if (opts?.withMark === false) {
+ return;
+ }
+ const mark = document.createElement("span");
+ mark.dataset["userIds"] = '["u1"]';
+ // jsdom has no layout, and the scroll skips marks without a box.
+ const content = document.createElement("span");
+ content.getBoundingClientRect = () =>
+ ({ width: 100, height: 20 }) as DOMRect;
+ mark.appendChild(content);
+ editor.domElement!.appendChild(mark);
+ },
+ exitPreview: () => {},
+ applyRestore: () => {},
+ },
+ scrollToFirstChange: opts?.scroll,
+ }));
+ return { editor, ext };
+ }
+
+ /** Wait out the delay between a preview rendering and its scroll. */
+ async function awaitScrollDelay() {
+ await new Promise((resolve) =>
+ setTimeout(resolve, SCROLL_TO_FIRST_CHANGE_DELAY_MS + 50),
+ );
+ }
+
+ // jsdom doesn't implement `scrollIntoView` at all, so this installs it
+ // rather than spying on an existing method.
+ const hadScrollIntoView = "scrollIntoView" in Element.prototype;
+ let scrollIntoView: ReturnType>;
+
+ beforeEach(() => {
+ scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ });
+
+ afterEach(() => {
+ if (!hadScrollIntoView) {
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
+ }
+ });
+
+ it("scrolls to the first attribution mark once, after a comparison", async () => {
+ const { editor, ext } = setupScrollProbe();
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+
+ editor.unmount();
+ });
+
+ it("does nothing when the preview has no attribution marks", async () => {
+ const { editor, ext } = setupScrollProbe({ withMark: false });
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ editor.unmount();
+ });
+
+ it("does nothing when disabled", async () => {
+ const { editor, ext } = setupScrollProbe({ scroll: false });
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ editor.unmount();
+ });
+ });
+
// -------------------------------------------------------------------------
// Exiting preview
// -------------------------------------------------------------------------
@@ -272,79 +756,306 @@ describe("VersioningExtension", () => {
describe("exiting preview", () => {
it("clears the preview state and restores the live document", async () => {
setEditorText(ctx.editor, "live content");
- const snap = await ctx.seed("snapshot content");
+ const seeded = await ctx.seed("snapshot content");
- await ctx.ext.previewSnapshot(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
expect(getEditorText(ctx.editor)).toBe("snapshot content");
ctx.ext.exitPreview();
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
expect(getEditorText(ctx.editor)).toBe("live content");
});
});
// -------------------------------------------------------------------------
- // Restoring snapshots
+ // The live document behind a preview
// -------------------------------------------------------------------------
- describe("restoring snapshots", () => {
- it("applies the snapshot content and exits any active preview", async () => {
- setEditorText(ctx.editor, "current doc");
- const snap = await ctx.seed("old content");
+ describe("the live document while previewing", () => {
+ it("previews the current version as the live document, not what is on screen", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("snapshot content");
- // Enter preview first, then restore.
- await ctx.ext.previewSnapshot(snap.id);
- await ctx.ext.restore!(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(getEditorText(ctx.editor)).toBe("snapshot content");
+ await ctx.ext.previewCurrentVersion!();
+ expect(getEditorText(ctx.editor)).toBe("live content");
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "current",
+ compareToId: undefined,
+ });
+ });
+
+ it("names the live document, not the previewed one", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("snapshot content");
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ const named = await ctx.ext.create!({ name: "named while previewing" });
+
+ await ctx.ext.previewSnapshot(named.id);
+ expect(getEditorText(ctx.editor)).toBe("live content");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Restoring
+ // -------------------------------------------------------------------------
+
+ describe("restoring versions", () => {
+ it("re-lists once after restoring", async () => {
+ const seeded = await ctx.seed("old content");
+ const list = vi.spyOn(ctx.endpoints, "list");
+ await ctx.ext.restore!(seeded.id);
+ expect(list).toHaveBeenCalledTimes(1);
+ expect(ctx.editor.isEditable).toBe(true);
expect(getEditorText(ctx.editor)).toBe("old content");
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
});
- it("picks up server-side backup snapshots after re-listing", async () => {
- const snap = await ctx.seed("original");
- await ctx.ext.list();
+ it("applies the version content and exits any active preview", async () => {
+ setEditorText(ctx.editor, "current doc");
+ const seeded = await ctx.seed("old content");
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ await ctx.ext.restore!(seeded.id);
- await ctx.ext.restore!(snap.id);
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ });
- // The in-memory endpoints create a backup snapshot on restore.
- const updated = await ctx.ext.list();
- expect(updated.length).toBe(2);
- expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe(
- true,
+ it("stays read-only and in preview until the backend has answered", async () => {
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ const gate = deferred();
+ const backendRestore = ctx.endpoints.restore!;
+ ctx.endpoints.restore = vi.fn(
+ async (doc: Block[], snapshot: VersionSnapshot) => {
+ await gate.promise;
+ return backendRestore(doc, snapshot);
+ },
);
+
+ const restoring = ctx.ext.restore!(seeded.id);
+ // Mid-restore: nothing can be typed into a document about to be replaced.
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view.mode).toBe("snapshot");
+
+ gate.resolve();
+ await restoring;
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it("stays read-only until the list has been refreshed as well", async () => {
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ const gate = deferred();
+ const backendList = ctx.endpoints.list;
+ ctx.endpoints.list = vi.fn(async () => {
+ await gate.promise;
+ return backendList();
+ });
+
+ const restoring = ctx.ext.restore!(seeded.id);
+ await vi.waitFor(() => expect(ctx.endpoints.list).toHaveBeenCalled());
+ // The restored content is already live, but remains read-only while
+ // the sidebar's list catches up.
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view.mode).toBe("live");
+ expect(getEditorText(ctx.editor)).toBe("old content");
+
+ gate.resolve();
+ await restoring;
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it.each([false, true])(
+ "keeps a successful restore when re-listing fails (preview: %s)",
+ async (previewing) => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("old content");
+ if (previewing) {
+ await ctx.ext.previewSnapshot(seeded.id);
+ }
+ ctx.endpoints.list = async () => {
+ throw new Error("list offline");
+ };
+
+ await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow(
+ "list offline",
+ );
+
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.ext.store.state.restoring).toBe(false);
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ },
+ );
+
+ it("leaves the user where they were when the backend rejects", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+ ctx.endpoints.restore = vi.fn(async () => {
+ throw new Error("network");
+ });
+
+ await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow("network");
+
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it("picks up server-side rows after re-listing", async () => {
+ const seeded = await ctx.seed("original");
+
+ await ctx.ext.restore!(seeded.id);
+
+ const updated = loadedList(ctx.ext);
+ expect(updated.snapshots).toHaveLength(2);
+ expect(updated.current.restoredFrom).toEqual({
+ id: seeded.id,
+ createdAt: seeded.createdAt,
+ });
});
it("reports restore as unavailable when endpoint omits it", () => {
const noRestore = setup({ withoutRestore: true });
- expect(noRestore.ext.canRestore).toBe(false);
expect(noRestore.ext.restore).toBeUndefined();
noRestore.editor.unmount();
});
+
+ it("reports restore as unavailable when the preview controller can't apply it", () => {
+ const noApply = setupWith((editor) => {
+ const controller = createInMemoryPreviewController(editor);
+ return {
+ endpoints: createInMemoryVersioningEndpoints(),
+ // Delegated rather than spread: the controller's
+ // `supportsComparison` is a getter that needs the mounted editor,
+ // so spreading it during `create` would throw. The methods are
+ // closure-based, so detaching them is safe.
+ preview: {
+ enterPreview: controller.enterPreview,
+ exitPreview: controller.exitPreview,
+ get supportsComparison() {
+ return controller.supportsComparison;
+ },
+ },
+ getCurrentDocument: () => controller.getLiveDocument(),
+ serializeCurrentContent: () => controller.getLiveDocument(),
+ };
+ });
+
+ expect(noApply.ext.restore).toBeUndefined();
+ noApply.editor.unmount();
+ });
});
// -------------------------------------------------------------------------
- // Updating snapshot names
+ // Removing
// -------------------------------------------------------------------------
- describe("updating snapshot names", () => {
- it("renames a snapshot in the store and backend", async () => {
- const snap = await ctx.seed("content", "Original");
- await ctx.ext.list();
+ describe("removing versions", () => {
+ it("exits the preview when the removed version is being previewed", async () => {
+ const seeded = await ctx.seed("content");
+ await ctx.ext.previewSnapshot(seeded.id);
- await ctx.ext.rename!(snap.id, "Renamed");
+ await ctx.ext.remove!(seeded.id);
- // Store was updated optimistically.
- expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed");
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(loadedList(ctx.ext).snapshots).toHaveLength(0);
+ });
+
+ it("exits the preview when the removed version is the baseline", async () => {
+ const baseline = await ctx.seed("content v1");
+ const shown = await ctx.seed("content v2");
+ await ctx.ext.previewSnapshot(shown.id, { compareTo: baseline.id });
+
+ await ctx.ext.remove!(baseline.id);
+
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(loadedList(ctx.ext).snapshots.map((s) => s.id)).toEqual([
+ shown.id,
+ ]);
+ });
+
+ it("keeps the preview when an unrelated version is removed", async () => {
+ const other = await ctx.seed("content v1");
+ const shown = await ctx.seed("content v2");
+ await ctx.ext.previewSnapshot(shown.id);
+
+ await ctx.ext.remove!(other.id);
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: undefined,
+ });
+ });
+
+ it("keeps the preview when the backend keeps the row", async () => {
+ // A continuous-history backend (YHub): removing a version only drops
+ // its name, and the row is still there to look at.
+ const shown = await ctx.seed("content", "named");
+ await ctx.ext.previewSnapshot(shown.id);
+ ctx.endpoints.remove = vi.fn(async (snapshot) => {
+ await ctx.endpoints.rename!(snapshot, undefined);
+ });
+
+ await ctx.ext.remove!(shown.id);
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: undefined,
+ });
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Renaming
+ // -------------------------------------------------------------------------
+
+ describe("renaming versions", () => {
+ it("renames a version in the store and backend", async () => {
+ const seeded = await ctx.seed("content", "Original");
+
+ await ctx.ext.rename!(seeded.id, "Renamed");
+
+ // Store was patched in place.
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBe("Renamed");
// Backend was also updated (verified via list).
const list = await ctx.ext.list();
- expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed");
+ expect(list.snapshots.find((s) => s.id === seeded.id)!.name).toBe(
+ "Renamed",
+ );
+ });
+
+ it("clears the name when renamed to undefined", async () => {
+ const seeded = await ctx.seed("content", "Original");
+
+ await ctx.ext.rename!(seeded.id, undefined);
+
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined();
});
it("reports name updates as unavailable when endpoint omits it", () => {
const noUpdate = setup({ withoutUpdateName: true });
- expect(noUpdate.ext.canRename).toBe(false);
expect(noUpdate.ext.rename).toBeUndefined();
noUpdate.editor.unmount();
});
@@ -379,23 +1090,23 @@ describe("VersioningExtension", () => {
});
it("passes `by` author ids through list() untouched", async () => {
- const editor = createEditor();
- const ext = VersioningExtension({
+ const { editor, ext } = setupWith(() => ({
endpoints: {
- list: async () => [snap("1", 100, { by: ["u1", "u2"] })],
+ list: async () => ({
+ current: snap("current", 200),
+ snapshots: [snap("1", 100, { by: ["u1", "u2"] })],
+ }),
getContent: async () => [],
- },
- preview: createInMemoryPreviewController(editor),
- getCurrentDocument: () => editor.document,
- })({ editor });
+ } satisfies VersioningEndpoints,
+ }));
const result = await ext.list();
// Raw ids are preserved — resolving them to user info is the view
// layer's job (via `ext.userStore`), never the extension's.
- expect(result[0]!.by).toEqual(["u1", "u2"]);
- expect(result[0]!.secondaryLabel).toBeUndefined();
- expect(ext.store.state.snapshots).toEqual(result);
+ expect(result.snapshots[0]!.by).toEqual(["u1", "u2"]);
+ expect(result.snapshots[0]!.secondaryLabel).toBeUndefined();
+ expect(ext.store.state.list).toEqual(result);
editor.unmount();
});
@@ -405,20 +1116,20 @@ describe("VersioningExtension", () => {
// End-to-end workflow
// -------------------------------------------------------------------------
- describe("workflow: create, preview with diff, then restore", () => {
+ describe("workflow: name, preview with diff, then restore", () => {
it("handles the full version-history flow", async () => {
vi.useFakeTimers();
- // 1. Create version 1.
+ // 1. Name version 1.
setEditorText(ctx.editor, "doc v1");
const v1 = await ctx.ext.create!({ name: "Version 1" });
vi.advanceTimersByTime(1000);
- // 2. Modify and create version 2.
+ // 2. Modify and name version 2.
setEditorText(ctx.editor, "doc v2");
const v2 = await ctx.ext.create!({ name: "Version 2" });
- expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id);
+ expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(v2.id);
// 3. Preview v1 with diff comparison against v2.
await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id });
@@ -427,7 +1138,7 @@ describe("VersioningExtension", () => {
// 4. Restore v1.
await ctx.ext.restore!(v1.id);
expect(getEditorText(ctx.editor)).toBe("doc v1");
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
vi.useRealTimers();
});
diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts
index cea8566ac5..4b049c73d6 100644
--- a/packages/core/src/extensions/Versioning/Versioning.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.ts
@@ -4,316 +4,29 @@ import {
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;
-
-/**
- * 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;
- /**
- * 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;
- /**
- * 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