diff --git a/examples/07-collaboration/15-yhub/.bnexample.json b/examples/07-collaboration/15-yhub/.bnexample.json
new file mode 100644
index 0000000000..3d1f711f56
--- /dev/null
+++ b/examples/07-collaboration/15-yhub/.bnexample.json
@@ -0,0 +1,12 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "",
+ "tags": ["Advanced", "Saving/Loading", "Collaboration"],
+ "dependencies": {
+ "@y/prosemirror": "^2.0.0-15",
+ "@y/protocols": "^1.0.6-rc.1",
+ "@y/websocket": "^4.0.0-3",
+ "@y/y": "^14.0.0-rc.26"
+ }
+}
diff --git a/examples/07-collaboration/15-yhub/README.md b/examples/07-collaboration/15-yhub/README.md
new file mode 100644
index 0000000000..6dac7ea935
--- /dev/null
+++ b/examples/07-collaboration/15-yhub/README.md
@@ -0,0 +1,11 @@
+# Collaborative Editing with YHub
+
+This example uses [YHub](https://github.com/yjs/yhub) and Yjs v14 to sync a BlockNote document and share user cursors over WebSocket. It connects to a public demo server, so don't enter private information.
+
+**Try it out:** Open this page in another browser tab or window to edit the same document together.
+
+**Relevant Docs:**
+
+- [Editor Setup](/docs/getting-started/editor-setup)
+- [Collaboration](/docs/features/collaboration)
+- [YHub](https://github.com/yjs/yhub)
diff --git a/examples/07-collaboration/15-yhub/index.html b/examples/07-collaboration/15-yhub/index.html
new file mode 100644
index 0000000000..5253e42990
--- /dev/null
+++ b/examples/07-collaboration/15-yhub/index.html
@@ -0,0 +1,17 @@
+
+
+ [0]);
diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css
index f603bd7806..027262c703 100644
--- a/packages/ariakit/src/style.css
+++ b/packages/ariakit/src/style.css
@@ -452,18 +452,16 @@
.bn-ariakit .bn-snapshot-date,
.bn-ariakit .bn-snapshot-original-date,
-.bn-ariakit .bn-snapshot-secondary-label {
+.bn-ariakit .bn-snapshot-users {
font-size: 13px;
line-height: 1.3;
}
-.bn-ariakit .bn-snapshot-original-date,
-.bn-ariakit .bn-snapshot-secondary-label {
+.bn-ariakit .bn-snapshot-original-date {
color: hsl(204 4% 40%);
}
-.bn-ariakit .bn-snapshot-original-date:where(.dark, .dark *),
-.bn-ariakit .bn-snapshot-secondary-label:where(.dark, .dark *) {
+.bn-ariakit .bn-snapshot-original-date:where(.dark, .dark *) {
color: hsl(204 8% 70%);
}
@@ -475,6 +473,34 @@
transition: opacity 0.12s ease;
}
+.bn-ariakit .bn-snapshot-users {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: visible;
+ white-space: normal;
+}
+
+.bn-ariakit .bn-snapshot-user {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.bn-ariakit .bn-snapshot-user-color {
+ background-color: currentColor;
+ border-radius: 50%;
+ flex: none;
+ height: 8px;
+ width: 8px;
+}
+
+.bn-ariakit .bn-snapshot-user-color:where(.dark, .dark *) {
+ background-color: var(--bn-user-color-light);
+}
+
.bn-ariakit .bn-snapshot:hover .bn-snapshot-menu,
.bn-ariakit .bn-snapshot:focus-visible .bn-snapshot-menu,
.bn-ariakit .bn-snapshot:focus-within .bn-snapshot-menu {
@@ -512,7 +538,6 @@
.bn-ariakit .bn-snapshot.selected .bn-snapshot-date,
.bn-ariakit .bn-snapshot.selected .bn-snapshot-original-date,
-.bn-ariakit .bn-snapshot.selected .bn-snapshot-secondary-label,
.bn-ariakit .bn-snapshot.selected .bn-snapshot-menu-trigger {
color: rgb(255 255 255 / 0.9);
}
diff --git a/packages/ariakit/src/versioning/Versioning.tsx b/packages/ariakit/src/versioning/Versioning.tsx
index 80b4c410f7..c731b5b8db 100644
--- a/packages/ariakit/src/versioning/Versioning.tsx
+++ b/packages/ariakit/src/versioning/Versioning.tsx
@@ -82,7 +82,7 @@ export const Snapshot = forwardRef<
name,
date,
restoredFrom,
- secondaryLabel,
+ users,
comparingLabel,
comparingIcon,
...rest
@@ -131,9 +131,28 @@ export const Snapshot = forwardRef<
{restoredFrom && (
{restoredFrom}
)}
- {secondaryLabel && (
- {secondaryLabel}
- )}
+ {users?.length ? (
+
+ {users.map((user, index) => (
+
+ {index > 0 && ·}
+ {typeof user !== "string" && user.color && (
+
+ )}
+ {typeof user === "string" ? user : user.username}
+
+ ))}
+
+ ) : null}
{actions && (
{
- if (!includeUpdatesFromRemote && isRemoteTransaction(transaction)) {
- // don't trigger the callback if the changes are caused by a remote user
+ if (
+ transaction.getMeta("blocknoteVersionPreview") ||
+ (!includeUpdatesFromRemote && isRemoteTransaction(transaction))
+ ) {
+ // Don't trigger the callback for previews or excluded remote changes.
return;
}
callback(this.editor, {
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index e7f95358d2..2090cea491 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -17,6 +17,7 @@ import {
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
+ LoadingExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
PositionMappingExtension,
@@ -174,6 +175,7 @@ export function getDefaultExtensions(
: {}),
}),
LinkToolbarExtension(options),
+ LoadingExtension(),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
diff --git a/packages/core/src/extensions/Loading/Loading.test.ts b/packages/core/src/extensions/Loading/Loading.test.ts
new file mode 100644
index 0000000000..523912e1c2
--- /dev/null
+++ b/packages/core/src/extensions/Loading/Loading.test.ts
@@ -0,0 +1,56 @@
+// @vitest-environment node
+import { afterEach, expect, it } from "vite-plus/test";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+import { LoadingExtension } from "./Loading.js";
+
+const editor = BlockNoteEditor.create();
+const loading = editor.getExtension(LoadingExtension)!;
+const readOnly = editor.getExtension(ReadOnlyExtension)!;
+
+afterEach(() => {
+ editor.isEditable = true;
+ readOnly.setReadOnly(false, "preview");
+});
+
+it("blocks editing until every loading token is released", () => {
+ const release = loading.acquire();
+ const releaseOther = loading.acquire();
+ try {
+ expect(editor.isEditable).toBe(false);
+ release();
+ release();
+ expect(editor.isEditable).toBe(false);
+ releaseOther();
+ expect(editor.isEditable).toBe(true);
+ } finally {
+ release();
+ releaseOther();
+ }
+});
+
+it("preserves application and independent feature restrictions", () => {
+ const release = loading.acquire();
+ editor.isEditable = false;
+ readOnly.setReadOnly(true, "preview");
+ release();
+ expect(editor.isEditable).toBe(false);
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(false);
+ readOnly.setReadOnly(false, "preview");
+ expect(editor.isEditable).toBe(true);
+});
+
+it("tracks tokens without a ReadOnly extension", () => {
+ const editor = BlockNoteEditor.create({ disableExtensions: ["readOnly"] });
+ try {
+ const loading = editor.getExtension(LoadingExtension)!;
+ expect(editor.getExtension(ReadOnlyExtension)).toBeUndefined();
+ const release = loading.acquire();
+ expect(loading.store.state.size).toBe(1);
+ release();
+ expect(loading.store.state.size).toBe(0);
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
diff --git a/packages/core/src/extensions/Loading/Loading.ts b/packages/core/src/extensions/Loading/Loading.ts
new file mode 100644
index 0000000000..0d9a221679
--- /dev/null
+++ b/packages/core/src/extensions/Loading/Loading.ts
@@ -0,0 +1,49 @@
+import {
+ createExtension,
+ createStore,
+} from "../../editor/BlockNoteExtension.js";
+import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+
+/** Keeps the editor read-only and the loader visible until every token is released. */
+export const LoadingExtension = createExtension(({ editor }) => {
+ const store = createStore(new Set
(), {
+ onUpdate(state, prevState) {
+ if (state.size > 0 === prevState.size > 0) {
+ return;
+ }
+ const readOnly = editor.getExtension(ReadOnlyExtension);
+ readOnly?.setReadOnly(state.size > 0, "loading");
+ },
+ });
+
+ return {
+ key: "loading",
+ store,
+ acquire() {
+ const token = Symbol();
+ store.setState(new Set([...store.state, token]));
+ return function release() {
+ if (store.state.has(token)) {
+ const tokens = new Set(store.state);
+ tokens.delete(token);
+ store.setState(tokens);
+ }
+ };
+ },
+ mount({ dom, signal }) {
+ function update() {
+ dom.classList.toggle("bn-loading", store.state.size > 0);
+ }
+ update();
+ const unsubscribe = store.subscribe(update);
+ signal.addEventListener(
+ "abort",
+ () => {
+ unsubscribe();
+ dom.classList.remove("bn-loading");
+ },
+ { once: true },
+ );
+ },
+ };
+});
diff --git a/packages/core/src/extensions/Versioning/Versioning.browser.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts
similarity index 79%
rename from packages/core/src/extensions/Versioning/Versioning.browser.test.ts
rename to packages/core/src/extensions/Versioning/Versioning.test.ts
index 4253d2b7ba..c519a80190 100644
--- a/packages/core/src/extensions/Versioning/Versioning.browser.test.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.test.ts
@@ -1,5 +1,7 @@
+// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { LoadingExtension } from "../Loading/Loading.js";
import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js";
import { createLocalVersioning } from "./inMemoryVersioning.js";
import { createVersioningExtension } from "./Versioning.js";
@@ -59,18 +61,41 @@ function setup(scrollToFirstChange = true, loadContent?: () => Promise) {
editor._tiptapEditor.destroy();
host.remove();
});
- const scroll = vi
- .spyOn(Element.prototype, "scrollIntoView")
- .mockImplementation(() => {});
+ // jsdom has no scrolling implementation. Assert scheduling and targeting only.
+ const scroll = vi.fn();
+ const originalScroll = Object.getOwnPropertyDescriptor(
+ Element.prototype,
+ "scrollIntoView",
+ );
+ Object.defineProperty(Element.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scroll,
+ });
+ cleanup.push(() => {
+ if (originalScroll) {
+ Object.defineProperty(
+ Element.prototype,
+ "scrollIntoView",
+ originalScroll,
+ );
+ } else {
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
+ }
+ });
vi.useFakeTimers();
mode.open();
return { editor, mode, scroll };
}
-it("scrolls to the first rendered change when comparing stored snapshots", async () => {
+it("schedules scrolling to the first change when comparing stored snapshots", async () => {
const { editor, mode, scroll } = setup();
await mode.select({ type: "snapshot", id: "2" }, { compareTo: "1" });
- expect(editor.domElement!.querySelector("[data-user-ids]")).not.toBeNull();
+ const mark = editor.domElement!.querySelector("[data-user-ids]")!;
+ expect(mark).not.toBeNull();
+ // Supply a visible box explicitly because jsdom does not calculate layout.
+ vi.spyOn(mark, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(0, 0, 100, 20),
+ );
await vi.advanceTimersByTimeAsync(SCROLL_TO_FIRST_CHANGE_DELAY_MS);
expect(scroll).toHaveBeenCalledWith({ block: "center", behavior: "smooth" });
});
@@ -116,6 +141,15 @@ it("clears the preview loader even for a fast selection", async () => {
expect(editor.domElement!.classList.contains("bn-loading")).toBe(false);
});
+it("preserves other features' loading tokens when a preview finishes", async () => {
+ const { editor, mode } = setup();
+ const release = editor.getExtension(LoadingExtension)!.acquire();
+ await mode.select({ type: "snapshot", id: "1" });
+ expect(editor.domElement!.classList.contains("bn-loading")).toBe(true);
+ release();
+ expect(editor.domElement!.classList.contains("bn-loading")).toBe(false);
+});
+
it("keeps the loader visible across successive selections", async () => {
const { promise, resolve } = Promise.withResolvers();
const { editor, mode } = setup(true, () => promise);
diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts
index 12b18f34e5..653b88f72c 100644
--- a/packages/core/src/extensions/Versioning/Versioning.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.ts
@@ -6,6 +6,7 @@ import type {
StyleSchema,
} from "../../schema/index.js";
import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+import { LoadingExtension } from "../Loading/Loading.js";
import { createVersioning } from "./createVersioning.js";
import type { VersionStorage, VersionViewAdapter } from "./types.js";
import { scheduleScrollToFirstChange } from "./scrollToFirstChange.js";
@@ -126,17 +127,24 @@ export function createVersioningExtension<
return getConfiguration().userStore;
},
mount() {
+ const loading = editor.getExtension(LoadingExtension);
+ let releaseLoading: (() => void) | undefined;
const unsubscribe = mode.store.subscribe(({ currentVal }) => {
- editor.domElement?.classList.toggle(
- "bn-loading",
- currentVal.mode === "versions" && currentVal.pending !== undefined,
- );
+ if (
+ currentVal.mode === "versions" &&
+ currentVal.pending !== undefined
+ ) {
+ releaseLoading ??= loading?.acquire();
+ } else {
+ releaseLoading?.();
+ releaseLoading = undefined;
+ }
});
return () => {
mode.close();
unsubscribe();
- editor.domElement?.classList.remove("bn-loading");
+ releaseLoading?.();
};
},
});
diff --git a/packages/core/src/extensions/Versioning/createVersioning.test.ts b/packages/core/src/extensions/Versioning/createVersioning.test.ts
index 0f0ff217db..75156af7c7 100644
--- a/packages/core/src/extensions/Versioning/createVersioning.test.ts
+++ b/packages/core/src/extensions/Versioning/createVersioning.test.ts
@@ -257,7 +257,7 @@ it("defers storage access and binds detached rename to the original storage", as
});
it.each(["create", "rename"] as const)(
- "publishes a successful %s locally before refresh and keeps success if refresh fails",
+ "publishes a successful %s locally and defers refresh until pagination",
async (operation) => {
const original = { id: "old", createdAt: 5, name: "Original" };
const refresh = deferred>();
@@ -276,6 +276,15 @@ it.each(["create", "rename"] as const)(
operation === "create"
? mode.create("Submitted")
: mode.rename("old", "Submitted");
+ expect(await saving).toEqual({ status: "done" });
+ expect(list).toHaveBeenCalledOnce();
+ expect(mode.store.state).toMatchObject({
+ history: {
+ status: "success",
+ data: [{ ...original, name: "Submitted" }],
+ },
+ });
+ const loading = mode.loadMore();
await vi.waitFor(() =>
expect(mode.store.state).toMatchObject({
history: {
@@ -285,7 +294,10 @@ it.each(["create", "rename"] as const)(
}),
);
refresh.resolve({ ok: false, error: { type: "network" } });
- expect(await saving).toEqual({ status: "done" });
+ expect(await loading).toEqual({
+ status: "error",
+ error: { type: "network" },
+ });
expect(mode.store.state).toMatchObject({
history: {
status: "error",
@@ -456,15 +468,18 @@ it("creates from frozen current even while displaying history", async () => {
it("keeps a selected checkpoint when removal only clears its name", async () => {
const { mode, show } = setup({
remove: async () => success(undefined),
- list: async () => success({ snapshots: [{ id: "old", createdAt: 1 }] }),
+ list: async () =>
+ success({ snapshots: [{ id: "old", createdAt: 1, name: "Draft" }] }),
});
mode.open();
+ await mode.list();
await mode.select({ type: "snapshot", id: "old" });
show.mockClear();
expect(await mode.remove("old")).toEqual({ status: "done" });
expect(show).not.toHaveBeenCalled();
expect(mode.store.state).toMatchObject({
displayed: { type: "snapshot", id: "old" },
+ history: { data: [{ id: "old", createdAt: 1, name: undefined }] },
});
});
@@ -791,7 +806,7 @@ describe("pagination", () => {
});
});
- it("resets pagination after naming and creation and verifies deleted selected content", async () => {
+ it("retains pagination after mutations and verifies deleted selected content", async () => {
let snapshots = [newest, middle, oldest];
const created = { id: "created", createdAt: 110 };
const { mode, show } = setup({
@@ -827,18 +842,148 @@ describe("pagination", () => {
await mode.loadMore();
await mode.rename(oldest.id, "Old draft");
await mode.create();
+ const namedOldest = { ...oldest, name: "Old draft" };
expect(mode.store.state).toMatchObject({
history: {
- data: [created, newest],
+ data: [created, newest, middle, namedOldest],
},
});
await mode.select({ type: "snapshot", id: middle.id });
await mode.remove(middle.id);
expect(mode.store.state).toMatchObject({
displayed: { type: "current" },
- history: { data: [created, newest] },
+ history: { data: [created, newest, middle, namedOldest] },
});
expect(show.mock.lastCall?.[0].content).toBe("frozen");
+ await mode.loadMore();
+ expect(mode.store.state).toMatchObject({
+ history: { data: [created, newest, namedOldest] },
+ nextCursor: undefined,
+ });
+ });
+
+ it("buffers dirty pages, deduplicates them, and retries without shrinking the list", async () => {
+ const page = deferred>();
+ const fresh = { ...newest, name: "Fresh" };
+ const list = vi
+ .fn["list"]>()
+ .mockResolvedValueOnce(
+ success({ snapshots: [newest, middle], nextCursor: "old" }),
+ )
+ .mockResolvedValueOnce(
+ success({ snapshots: [fresh], nextCursor: "middle" }),
+ )
+ .mockReturnValueOnce(page.promise)
+ .mockResolvedValueOnce(
+ success({ snapshots: [fresh], nextCursor: "middle" }),
+ )
+ .mockResolvedValueOnce(
+ success({ snapshots: [fresh, middle], nextCursor: "oldest" }),
+ )
+ .mockResolvedValueOnce(
+ success({ snapshots: [oldest], nextCursor: "next" }),
+ );
+ const { mode } = setup({ list, rename: async () => success(undefined) });
+ mode.open();
+ await mode.list();
+ await mode.rename(newest.id, "Fresh");
+ expect(list).toHaveBeenCalledOnce();
+ const loading = mode.loadMore();
+ expect(mode.loadMore()).toBe(loading);
+ await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3));
+ expect(mode.store.state).toMatchObject({
+ history: { status: "pending", data: [fresh, middle] },
+ nextCursor: "old",
+ });
+ page.resolve({ ok: false, error: { type: "network" } });
+ await loading;
+ expect(mode.store.state).toMatchObject({
+ history: { status: "error", data: [fresh, middle] },
+ nextCursor: "old",
+ });
+ await mode.loadMore();
+ expect(list.mock.calls.slice(1).map(([, cursor]) => cursor)).toEqual([
+ undefined,
+ "middle",
+ undefined,
+ "middle",
+ "oldest",
+ ]);
+ expect(mode.store.state).toMatchObject({
+ history: { status: "success", data: [fresh, middle, oldest] },
+ nextCursor: "next",
+ });
+ });
+
+ it.each(["loadMore", "list", "dirty refresh"] as const)(
+ "cancels a stale %s on mutation and permits pagination",
+ async (operation) => {
+ const stale = deferred>();
+ const fresh = { ...newest, name: "Fresh" };
+ const list = vi
+ .fn["list"]>()
+ .mockResolvedValueOnce(
+ success({ snapshots: [newest], nextCursor: "old" }),
+ )
+ .mockReturnValueOnce(stale.promise)
+ .mockResolvedValueOnce(success({ snapshots: [fresh, middle] }));
+ const { mode } = setup({
+ list,
+ rename: async () => success(undefined),
+ remove: async () => success(undefined),
+ });
+ mode.open();
+ await mode.list();
+ if (operation === "dirty refresh") {
+ await mode.rename(newest.id, "Fresh");
+ }
+ const pending = operation === "list" ? mode.list() : mode.loadMore();
+ if (operation === "dirty refresh") {
+ await mode.remove(newest.id);
+ } else {
+ await mode.rename(newest.id, "Fresh");
+ }
+ expect(mode.store.state).toMatchObject({
+ history: {
+ status: "success",
+ data: [
+ operation === "dirty refresh"
+ ? { ...fresh, name: undefined }
+ : fresh,
+ ],
+ },
+ });
+ await mode.loadMore();
+ stale.resolve(success({ snapshots: [newest, oldest] }));
+ expect(await pending).toEqual({ status: "cancelled" });
+ expect(mode.store.state).toMatchObject({
+ history: { status: "success", data: [fresh, middle] },
+ });
+ },
+ );
+
+ it("clears dirty history on manual refresh and keeps the normal append cursor", async () => {
+ const list = vi
+ .fn["list"]>()
+ .mockResolvedValueOnce(
+ success({ snapshots: [newest, middle], nextCursor: "old" }),
+ )
+ .mockResolvedValueOnce(
+ success({ snapshots: [newest], nextCursor: "fresh" }),
+ )
+ .mockResolvedValueOnce(success({ snapshots: [middle] }));
+ const { mode } = setup({ list, rename: async () => success(undefined) });
+ mode.open();
+ await mode.list();
+ await mode.rename(newest.id, "Named");
+ await mode.list();
+ expect(mode.store.state).toMatchObject({ history: { data: [newest] } });
+ await mode.loadMore();
+ expect(list.mock.calls.map(([, cursor]) => cursor)).toEqual([
+ undefined,
+ undefined,
+ "fresh",
+ ]);
});
it("allows a manual load after the initial page fails", async () => {
diff --git a/packages/core/src/extensions/Versioning/createVersioning.ts b/packages/core/src/extensions/Versioning/createVersioning.ts
index 037d5b1b58..18343efa1d 100644
--- a/packages/core/src/extensions/Versioning/createVersioning.ts
+++ b/packages/core/src/extensions/Versioning/createVersioning.ts
@@ -12,6 +12,7 @@ import type {
VersionStorage,
VersionView,
VersionViewAdapter,
+ VersionSnapshot,
} from "./types.js";
/** One replaceable read per slot. The session lifetime cancels both slots. */
@@ -56,6 +57,7 @@ export function createVersioning(options: {
selectionBaseline?: string;
history: ReturnType;
loadingMore?: Promise;
+ historyDirty?: boolean;
};
let session: Session | undefined;
let disposed = false;
@@ -134,7 +136,7 @@ export function createVersioning(options: {
return readHistory(active, { operation: "refresh" });
}
- /** Append one page. Concurrent callers share the same request. */
+ /** Append one page, rebuilding invalidated history first. Concurrent callers share a request. */
function loadMore(): Promise {
const active = session;
const state = store.state;
@@ -144,6 +146,12 @@ export function createVersioning(options: {
if (active.loadingMore) {
return active.loadingMore;
}
+ if (active.historyDirty) {
+ return readHistory(active, {
+ operation: "refresh",
+ growPast: state.history.data?.length ?? 0,
+ });
+ }
if (
state.history.status === "error" &&
state.history.operation === "refresh"
@@ -161,37 +169,54 @@ export function createVersioning(options: {
function readHistory(
active: Session,
- read: { operation: "refresh" } | { operation: "loadMore"; cursor: string },
+ read:
+ | { operation: "refresh"; growPast?: number }
+ | { operation: "loadMore"; cursor: string },
): Promise {
const { signal } = active.history.start();
publish(signal, { type: "historyStarted", operation: read.operation });
const pending = (async (): Promise => {
try {
- const result = await options.storage.list(
- signal,
- read.operation === "refresh" ? undefined : read.cursor,
- );
- if (signal.aborted) {
- return { status: "cancelled" };
- }
- if (!result.ok) {
- publish(signal, {
- type: "historyFailed",
- operation: read.operation,
- error: result.error,
- });
- return { status: "error", error: result.error };
+ const snapshots = new Map();
+ let cursor = read.operation === "loadMore" ? read.cursor : undefined;
+ // Keep loaded rows visible until their replacement and the next page are ready.
+ while (true) {
+ const result = await options.storage.list(signal, cursor);
+ if (signal.aborted) {
+ return { status: "cancelled" };
+ }
+ if (!result.ok) {
+ publish(signal, {
+ type: "historyFailed",
+ operation: read.operation,
+ error: result.error,
+ });
+ return { status: "error", error: result.error };
+ }
+ if (cursor !== undefined && result.value.nextCursor === cursor) {
+ throw new Error("Version history cursor did not advance");
+ }
+ for (const snapshot of result.value.snapshots) {
+ snapshots.set(snapshot.id, snapshot);
+ }
+ cursor = result.value.nextCursor;
+ if (
+ read.operation === "loadMore" ||
+ read.growPast === undefined ||
+ snapshots.size > read.growPast ||
+ cursor === undefined
+ ) {
+ break;
+ }
}
- if (
- read.operation === "loadMore" &&
- result.value.nextCursor === read.cursor
- ) {
- throw new Error("Version history cursor did not advance");
+ if (read.operation === "refresh") {
+ active.historyDirty = false;
}
const loaded = publish(signal, {
type: "historyLoaded",
operation: read.operation,
- ...result.value,
+ snapshots: [...snapshots.values()],
+ nextCursor: cursor,
});
if (signal.aborted) {
return { status: "cancelled" };
@@ -217,7 +242,10 @@ export function createVersioning(options: {
}
}
})();
- if (read.operation === "loadMore" && !signal.aborted) {
+ if (
+ (read.operation === "loadMore" || read.growPast !== undefined) &&
+ !signal.aborted
+ ) {
active.loadingMore = pending;
}
return pending;
@@ -360,16 +388,23 @@ export function createVersioning(options: {
}
}
- async function refreshAfterNaming(
+ function invalidateHistory(active: Session) {
+ active.history.cancel();
+ active.loadingMore = undefined;
+ active.historyDirty = true;
+ publish(active.lifetime.signal, { type: "historyInvalidated" });
+ }
+
+ function applyNamingResult(
active: Session,
event: Extract<
VersioningEvent,
{ type: "snapshotRenamed" | "snapshotCreated" }
>,
) {
- publish(active.lifetime.signal, event);
if (!active.lifetime.signal.aborted) {
- await list();
+ invalidateHistory(active);
+ publish(active.lifetime.signal, event);
}
}
@@ -387,7 +422,7 @@ export function createVersioning(options: {
return { status: "error", error: result.error };
}
if (active) {
- await refreshAfterNaming(active, { type: "snapshotRenamed", id, name });
+ applyNamingResult(active, { type: "snapshotRenamed", id, name });
}
return { status: "done" };
}
@@ -447,7 +482,7 @@ export function createVersioning(options: {
if (!result.ok) {
return { status: "error", error: result.error };
}
- await refreshAfterNaming(active, {
+ applyNamingResult(active, {
type: "snapshotCreated",
snapshot: result.value,
});
@@ -464,7 +499,9 @@ export function createVersioning(options: {
return { status: "error", error: removed.error };
}
if (!active.lifetime.signal.aborted) {
- await list();
+ // Both deletion and continuous-history removal clear the named marker.
+ // Keep cached checkpoints for pagination, but not as named baselines.
+ applyNamingResult(active, { type: "snapshotRenamed", id });
// Missing page metadata does not prove deletion. Continuous-history
// providers may only clear a name, leaving its content available.
const state = store.state;
diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
index 4c5a3b85eb..47c2a3f6f0 100644
--- a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
+++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
@@ -475,7 +475,46 @@ it("isolates displayed content and preserves live undo across open/show/close",
}
});
-it("restores into the saved live state, then closes the isolated view", async () => {
+it.each([false, true])(
+ "does not publish previews as changes, with comparison %s",
+ async (compare) => {
+ const editor = BlockNoteEditor.create({
+ initialContent: [{ id: "paragraph", content: "live" }],
+ extensions: [
+ DiffVersioningExtension(),
+ InMemoryVersioningExtension({
+ initialVersions: [
+ { content: [{ id: "paragraph", content: "saved" }], createdAt: 1 },
+ ],
+ }),
+ ],
+ });
+ const mode = editor.getExtension(InMemoryVersioningExtension)!;
+ const changes: string[] = [];
+ editor.onChange(() =>
+ changes.push(editor.prosemirrorState.doc.textContent),
+ );
+ try {
+ mode.open();
+ expect(
+ await mode.select(
+ compare ? { type: "current" } : { type: "snapshot", id: "1" },
+ compare ? { compareTo: "1" } : undefined,
+ ),
+ ).toEqual({ status: "done" });
+ await mode.select({ type: "current" });
+ mode.close();
+ expect(changes).toEqual([]);
+ editor.updateBlock("paragraph", { content: "edited" });
+ expect(changes).toEqual(["edited"]);
+ } finally {
+ mode.dispose();
+ editor._tiptapEditor.destroy();
+ }
+ },
+);
+
+it("publishes a restore as a live document change after closing the isolated view", async () => {
const extension = createVersioningExtension(createLocalVersioning);
const editor = BlockNoteEditor.create({ extensions: [extension()] });
const mode = editor.getExtension(extension)!;
@@ -495,12 +534,22 @@ it("restores into the saved live state, then closes the isolated view", async ()
{ type: "paragraph", content: "Latest" },
]);
mode.open();
+ const changes: string[] = [];
+ editor.onChange(() =>
+ changes.push(editor.prosemirrorState.doc.textContent),
+ );
await mode.select({ type: "snapshot", id: saved.id });
await mode.select({ type: "current" });
expect(editor.prosemirrorState.doc.textContent).toBe("Latest");
+ changes.length = 0;
await mode.restore(saved.id);
+ expect(changes).toEqual(["Original"]);
expect(editor.prosemirrorState.doc.textContent).toBe("Original");
expect(editor.isEditable).toBe(true);
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "After restore" },
+ ]);
+ expect(changes).toEqual(["Original", "After restore"]);
} finally {
mode.dispose();
editor._tiptapEditor.destroy();
diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
index 71a6ecea06..7fa8a7d511 100644
--- a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
+++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
@@ -96,6 +96,7 @@ export function createLocalVersioning<
storage: VersionStorage;
} {
let live: EditorState | undefined;
+ let closeView: (() => void) | undefined;
let nextId = 0;
const snapshots = new Map<
string,
@@ -168,6 +169,23 @@ export function createLocalVersioning<
live = undefined;
throw error;
}
+ function close() {
+ if (closed) {
+ return;
+ }
+ if (!live) {
+ throw new Error("Missing live editor state");
+ }
+ editor.prosemirrorView.updateState(
+ live.reconfigure({
+ plugins: editor.prosemirrorState.plugins,
+ }),
+ );
+ closed = true;
+ live = undefined;
+ closeView = undefined;
+ }
+ closeView = close;
return {
current,
show({ content, comparison }) {
@@ -189,23 +207,10 @@ export function createLocalVersioning<
editor.transact((tr) => {
tr.replaceWith(0, tr.doc.content.size, document.content);
tr.setMeta("addToHistory", false);
+ tr.setMeta("blocknoteVersionPreview", true);
});
},
- close() {
- if (closed) {
- return;
- }
- if (!live) {
- throw new Error("Missing live editor state");
- }
- editor.prosemirrorView.updateState(
- live.reconfigure({
- plugins: editor.prosemirrorState.plugins,
- }),
- );
- closed = true;
- live = undefined;
- },
+ close,
};
},
},
@@ -240,15 +245,12 @@ export function createLocalVersioning<
return { ok: false, error: { type: "not-found" } };
}
const content = inEditorSchema(stored.content);
- if (live) {
- live = live.apply(
- live.tr.replaceWith(0, live.doc.content.size, content.content),
- );
- } else {
- editor.transact((tr) =>
- tr.replaceWith(0, tr.doc.content.size, content.content),
- );
- }
+ // Restore the live state before dispatch so change listeners observe
+ // the restored document, not a change to the temporary preview.
+ closeView?.();
+ editor.transact((tr) =>
+ tr.replaceWith(0, tr.doc.content.size, content.content),
+ );
return { ok: true, value: undefined };
},
async rename(id, name) {
diff --git a/packages/core/src/extensions/Versioning/types.ts b/packages/core/src/extensions/Versioning/types.ts
index 1dab09a917..3f20feefe5 100644
--- a/packages/core/src/extensions/Versioning/types.ts
+++ b/packages/core/src/extensions/Versioning/types.ts
@@ -12,8 +12,6 @@ export interface VersionSnapshot {
name?: string;
/** Author identifier or identifiers, resolved to users by the sidebar's user store. */
by?: string | string[];
- /** Optional additional text displayed beneath the version's name or timestamp. */
- secondaryLabel?: string;
/** Original version whose content produced this version through a restore. */
restoredFrom?: { id: string; createdAt: number };
/** Backend-specific metadata, opaque to the versioning controller. */
@@ -148,6 +146,8 @@ export interface VersionStorage {
* Name current. Snapshot stores save `content` from {@link VersionView.current};
* continuous-history stores must identify the captured checkpoint, not a later one.
* Success returns checkpoint metadata. No checkpoint to name is a typed failure.
+ * The controller keeps local metadata and refreshes history on the next
+ * pagination request.
*/
create?: (
content: Content,
@@ -162,11 +162,12 @@ export interface VersionStorage {
* restored Current.
*/
restore?: (id: string) => Promise>;
- /** Change a stored version's name; `undefined` clears it. Refresh {@link VersionStorage.list} afterward. */
+ /** Change a stored version's name; `undefined` clears it. History is refreshed on the next pagination request. */
rename?: (id: string, name?: string) => Promise>;
/**
* Remove a stored version, or only its name on continuous-history backends.
- * The controller refreshes {@link VersionStorage.list} and reconciles selection.
+ * The controller reconciles selection immediately and refreshes history on the
+ * next pagination request. Existing rows remain visible until that refresh.
*/
remove?: (id: string) => Promise>;
}
diff --git a/packages/core/src/extensions/Versioning/versioningState.ts b/packages/core/src/extensions/Versioning/versioningState.ts
index 036f7010da..8d7cb8f499 100644
--- a/packages/core/src/extensions/Versioning/versioningState.ts
+++ b/packages/core/src/extensions/Versioning/versioningState.ts
@@ -14,6 +14,7 @@ export type VersioningEvent =
}
| { type: "closed"; restoring?: boolean }
| { type: "historyStarted"; operation: "refresh" | "loadMore" }
+ | { type: "historyInvalidated" }
| {
type: "historyFailed";
operation: "refresh" | "loadMore";
@@ -67,6 +68,12 @@ export function reduceVersioningState(
return state;
}
switch (event.type) {
+ case "historyInvalidated":
+ return {
+ ...state,
+ mode: "versions",
+ history: { status: "success", data: state.history.data ?? [] },
+ };
case "historyStarted":
return {
...state,
diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts
index c9ed09e7b4..cbb5946e7b 100644
--- a/packages/core/src/extensions/index.ts
+++ b/packages/core/src/extensions/index.ts
@@ -6,6 +6,7 @@ export * from "./History/History.js";
export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js";
export * from "./LinkToolbar/LinkToolbar.js";
export * from "./LinkToolbar/protocols.js";
+export * from "./Loading/Loading.js";
export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js";
export * from "./Placeholder/Placeholder.js";
export * from "./PositionMapping/PositionMapping.js";
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 99ad5ee6e8..139294fc58 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -410,8 +410,8 @@ export const ar: Dictionary = {
close: "إغلاق",
show_named_only: "إظهار الإصدارات المسماة فقط",
show_all: "إظهار جميع الإصدارات",
- comparison_on: "تفعيل المقارنة",
- comparison_off: "إيقاف المقارنة",
+ comparison_on: "إظهار التغييرات",
+ comparison_off: "إخفاء التغييرات",
versions_list: "الإصدارات",
empty: "لا توجد إصدارات بعد",
empty_named_only: "لا توجد إصدارات مسماة",
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 1e8c3851f6..52f8569a5f 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -444,8 +444,8 @@ export const de: Dictionary = {
close: "Schließen",
show_named_only: "Nur benannte Versionen anzeigen",
show_all: "Alle Versionen anzeigen",
- comparison_on: "Vergleich einschalten",
- comparison_off: "Vergleich ausschalten",
+ comparison_on: "Änderungen anzeigen",
+ comparison_off: "Änderungen ausblenden",
versions_list: "Versionen",
empty: "Noch keine Versionen",
empty_named_only: "Keine benannten Versionen",
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index 860d26f37a..cf08a92e80 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -425,8 +425,8 @@ export const en = {
close: "Close",
show_named_only: "Show named versions only",
show_all: "Show all versions",
- comparison_on: "Turn on comparison",
- comparison_off: "Turn off comparison",
+ comparison_on: "Show changes",
+ comparison_off: "Hide changes",
versions_list: "Versions",
empty: "No versions yet",
empty_named_only: "No named versions",
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index 7ed2da20b3..b17914331d 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -423,8 +423,8 @@ export const es: Dictionary = {
close: "Cerrar",
show_named_only: "Mostrar solo versiones con nombre",
show_all: "Mostrar todas las versiones",
- comparison_on: "Activar comparación",
- comparison_off: "Desactivar comparación",
+ comparison_on: "Mostrar cambios",
+ comparison_off: "Ocultar cambios",
versions_list: "Versiones",
empty: "Aún no hay versiones",
empty_named_only: "No hay versiones con nombre",
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index 9de694d62d..382e7e9d64 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -394,8 +394,8 @@ export const fa = {
close: "بستن",
show_named_only: "فقط نسخههای نامگذاریشده نمایش داده شود",
show_all: "نمایش همه نسخهها",
- comparison_on: "روشن کردن مقایسه",
- comparison_off: "خاموش کردن مقایسه",
+ comparison_on: "نمایش تغییرات",
+ comparison_off: "پنهان کردن تغییرات",
versions_list: "نسخهها",
empty: "هنوز نسخهای وجود ندارد",
empty_named_only: "نسخهای با نام وجود ندارد",
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index 89edab7765..42693f10cb 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -471,8 +471,8 @@ export const fr: Dictionary = {
close: "Fermer",
show_named_only: "Afficher uniquement les versions nommées",
show_all: "Afficher toutes les versions",
- comparison_on: "Activer la comparaison",
- comparison_off: "Désactiver la comparaison",
+ comparison_on: "Afficher les modifications",
+ comparison_off: "Masquer les modifications",
versions_list: "Versions",
empty: "Aucune version pour l'instant",
empty_named_only: "Aucune version nommée",
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index b839862ac3..5a64a68199 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -425,8 +425,8 @@ export const he: Dictionary = {
close: "סגירה",
show_named_only: "הצג גרסאות בעלות שם בלבד",
show_all: "הצג את כל הגרסאות",
- comparison_on: "הפעלת השוואה",
- comparison_off: "כיבוי השוואה",
+ comparison_on: "הצגת שינויים",
+ comparison_off: "הסתרת שינויים",
versions_list: "גרסאות",
empty: "אין עדיין גרסאות",
empty_named_only: "אין גרסאות עם שם",
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index 34d6aec49c..7b7f777ae7 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -439,8 +439,8 @@ export const hr: Dictionary = {
close: "Zatvori",
show_named_only: "Prikaži samo imenovane verzije",
show_all: "Prikaži sve verzije",
- comparison_on: "Uključi usporedbu",
- comparison_off: "Isključi usporedbu",
+ comparison_on: "Prikaži promjene",
+ comparison_off: "Sakrij promjene",
versions_list: "Verzije",
empty: "Još nema verzija",
empty_named_only: "Nema imenovanih verzija",
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index 290fb963dc..7200ded06d 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -439,8 +439,8 @@ export const is: Dictionary = {
close: "Loka",
show_named_only: "Sýna aðeins nefndar útgáfur",
show_all: "Sýna allar útgáfur",
- comparison_on: "Kveikja á samanburði",
- comparison_off: "Slökkva á samanburði",
+ comparison_on: "Sýna breytingar",
+ comparison_off: "Fela breytingar",
versions_list: "Útgáfur",
empty: "Engar útgáfur enn",
empty_named_only: "Engar nefndar útgáfur",
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 72ff93db0a..84cfefeb88 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -447,8 +447,8 @@ export const it: Dictionary = {
close: "Chiudi",
show_named_only: "Mostra solo le versioni con nome",
show_all: "Mostra tutte le versioni",
- comparison_on: "Attiva il confronto",
- comparison_off: "Disattiva il confronto",
+ comparison_on: "Mostra modifiche",
+ comparison_off: "Nascondi modifiche",
versions_list: "Versioni",
empty: "Nessuna versione",
empty_named_only: "Nessuna versione con nome",
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index 47c11ce4e9..794d9548e5 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -465,8 +465,8 @@ export const ja: Dictionary = {
close: "閉じる",
show_named_only: "名前付きバージョンのみ表示",
show_all: "すべてのバージョンを表示",
- comparison_on: "比較を有効にする",
- comparison_off: "比較を無効にする",
+ comparison_on: "変更を表示",
+ comparison_off: "変更を非表示",
versions_list: "バージョン",
empty: "バージョンはまだありません",
empty_named_only: "名前付きのバージョンはありません",
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index d7cce2f392..e68e1bc420 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -438,8 +438,8 @@ export const ko: Dictionary = {
close: "닫기",
show_named_only: "이름이 지정된 버전만 표시",
show_all: "모든 버전 표시",
- comparison_on: "비교 켜기",
- comparison_off: "비교 끄기",
+ comparison_on: "변경 사항 표시",
+ comparison_off: "변경 사항 숨기기",
versions_list: "버전",
empty: "아직 버전이 없습니다",
empty_named_only: "이름이 지정된 버전이 없습니다",
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index 4cd90d5383..bea4c51e34 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -426,8 +426,8 @@ export const nl: Dictionary = {
close: "Sluiten",
show_named_only: "Alleen benoemde versies tonen",
show_all: "Alle versies tonen",
- comparison_on: "Vergelijking inschakelen",
- comparison_off: "Vergelijking uitschakelen",
+ comparison_on: "Wijzigingen tonen",
+ comparison_off: "Wijzigingen verbergen",
versions_list: "Versies",
empty: "Nog geen versies",
empty_named_only: "Geen benoemde versies",
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 8cc16e93ee..cdef7ba883 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -443,8 +443,8 @@ export const no: Dictionary = {
close: "Lukk",
show_named_only: "Vis bare navngitte versjoner",
show_all: "Vis alle versjoner",
- comparison_on: "Slå på sammenligning",
- comparison_off: "Slå av sammenligning",
+ comparison_on: "Vis endringer",
+ comparison_off: "Skjul endringer",
versions_list: "Versjoner",
empty: "Ingen versjoner ennå",
empty_named_only: "Ingen navngitte versjoner",
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index 3f25c5f07c..ccf5c9410c 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -416,8 +416,8 @@ export const pl: Dictionary = {
close: "Zamknij",
show_named_only: "Pokaż tylko nazwane wersje",
show_all: "Pokaż wszystkie wersje",
- comparison_on: "Włącz porównywanie",
- comparison_off: "Wyłącz porównywanie",
+ comparison_on: "Pokaż zmiany",
+ comparison_off: "Ukryj zmiany",
versions_list: "Wersje",
empty: "Brak wersji",
empty_named_only: "Brak nazwanych wersji",
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index ebac76c283..49b346bec8 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -418,8 +418,8 @@ export const pt: Dictionary = {
close: "Fechar",
show_named_only: "Mostrar apenas versões nomeadas",
show_all: "Mostrar todas as versões",
- comparison_on: "Ativar comparação",
- comparison_off: "Desativar comparação",
+ comparison_on: "Mostrar alterações",
+ comparison_off: "Ocultar alterações",
versions_list: "Versões",
empty: "Ainda não há versões",
empty_named_only: "Não há versões nomeadas",
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index 0cc71efc3a..45cb8637e2 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -469,8 +469,8 @@ export const ru: Dictionary = {
close: "Закрыть",
show_named_only: "Показывать только именованные версии",
show_all: "Показывать все версии",
- comparison_on: "Включить сравнение",
- comparison_off: "Выключить сравнение",
+ comparison_on: "Показать изменения",
+ comparison_off: "Скрыть изменения",
versions_list: "Версии",
empty: "Пока нет версий",
empty_named_only: "Нет именованных версий",
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index 3bd8f0f7aa..19280c296d 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -423,8 +423,8 @@ export const sk = {
close: "Zavrieť",
show_named_only: "Zobraziť iba pomenované verzie",
show_all: "Zobraziť všetky verzie",
- comparison_on: "Zapnúť porovnávanie",
- comparison_off: "Vypnúť porovnávanie",
+ comparison_on: "Zobraziť zmeny",
+ comparison_off: "Skryť zmeny",
versions_list: "Verzie",
empty: "Zatiaľ žiadne verzie",
empty_named_only: "Žiadne pomenované verzie",
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index f02b177996..1ea27740d9 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -449,8 +449,8 @@ export const uk: Dictionary = {
close: "Закрити",
show_named_only: "Показувати лише названі версії",
show_all: "Показувати всі версії",
- comparison_on: "Увімкнути порівняння",
- comparison_off: "Вимкнути порівняння",
+ comparison_on: "Показати зміни",
+ comparison_off: "Приховати зміни",
versions_list: "Версії",
empty: "Версій ще немає",
empty_named_only: "Немає іменованих версій",
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index 182d0bdc4b..aa33db816d 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -459,8 +459,8 @@ export const uz: Dictionary = {
close: "Yopish",
show_named_only: "Faqat nomlangan versiyalarni ko'rsatish",
show_all: "Barcha versiyalarni ko'rsatish",
- comparison_on: "Taqqoslashni yoqish",
- comparison_off: "Taqqoslashni o'chirish",
+ comparison_on: "O'zgarishlarni ko'rsatish",
+ comparison_off: "O'zgarishlarni yashirish",
versions_list: "Versiyalar",
empty: "Hozircha versiyalar yo'q",
empty_named_only: "Nomlangan versiyalar yo'q",
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index c9d3396846..ed926fd637 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -424,8 +424,8 @@ export const vi: Dictionary = {
close: "Đóng",
show_named_only: "Chỉ hiển thị các phiên bản đã đặt tên",
show_all: "Hiển thị tất cả phiên bản",
- comparison_on: "Bật so sánh",
- comparison_off: "Tắt so sánh",
+ comparison_on: "Hiện thay đổi",
+ comparison_off: "Ẩn thay đổi",
versions_list: "Phiên bản",
empty: "Chưa có phiên bản nào",
empty_named_only: "Không có phiên bản nào được đặt tên",
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index 5bc0608037..41042e5734 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -466,8 +466,8 @@ export const zhTW: Dictionary = {
close: "關閉",
show_named_only: "僅顯示已命名的版本",
show_all: "顯示所有版本",
- comparison_on: "開啟比較",
- comparison_off: "關閉比較",
+ comparison_on: "顯示變更",
+ comparison_off: "隱藏變更",
versions_list: "版本",
empty: "尚無版本",
empty_named_only: "尚無命名版本",
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index c262bc0d48..15a0fb0646 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -466,8 +466,8 @@ export const zh: Dictionary = {
close: "关闭",
show_named_only: "仅显示已命名的版本",
show_all: "显示所有版本",
- comparison_on: "开启对比",
- comparison_off: "关闭对比",
+ comparison_on: "显示更改",
+ comparison_off: "隐藏更改",
versions_list: "版本",
empty: "暂无版本",
empty_named_only: "暂无命名版本",
diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.ts b/packages/core/src/y/extensions/DiffVersioningExtension.ts
index e5cc51e39b..c349fb9529 100644
--- a/packages/core/src/y/extensions/DiffVersioningExtension.ts
+++ b/packages/core/src/y/extensions/DiffVersioningExtension.ts
@@ -186,6 +186,7 @@ export const DiffVersioningExtension = createExtension(
// document first would only churn node views for no gain.
editor.exec((state, dispatch) => {
const tr = yNodeToTransaction(state.tr, nextType, { renderer });
+ tr.setMeta("blocknoteVersionPreview", true);
if (dispatch) {
dispatch(tr);
}
diff --git a/packages/core/src/y/extensions/Versioning.test.ts b/packages/core/src/y/extensions/Versioning.test.ts
index 6edd3f8f34..d205f360fe 100644
--- a/packages/core/src/y/extensions/Versioning.test.ts
+++ b/packages/core/src/y/extensions/Versioning.test.ts
@@ -10,10 +10,21 @@ import type { VersioningController } from "../../extensions/Versioning/Versionin
import { YVersioningExtension } from "../versioning/yhub.js";
import type { VersionStorage } from "../../extensions/Versioning/types.js";
-it.each([undefined, false])(
- "resolves the collaboration fragment and isolates live changes, Current capture=%s",
- async (showCurrentVersion) => {
+it.each(
+ [undefined, false].flatMap((showCurrentVersion) =>
+ [false, true].map((withSuggestions) => ({
+ showCurrentVersion,
+ withSuggestions,
+ })),
+ ),
+)(
+ "isolates previews and restores the live binding, Current capture=$showCurrentVersion, suggestions=$withSuggestions",
+ async ({ showCurrentVersion, withSuggestions }) => {
const doc = new Y.Doc();
+ const suggestionDoc = new Y.Doc({ isSuggestionDoc: true });
+ const renderer = withSuggestions
+ ? Y.createDiffRenderer(doc, suggestionDoc)
+ : undefined;
const fragment = doc.get("doc");
let saved: Uint8Array = new Uint8Array();
const storage: VersionStorage = {
@@ -31,7 +42,11 @@ it.each([undefined, false])(
storage,
}),
],
- collaboration: { fragment, user: { name: "Test", color: "red" } },
+ collaboration: {
+ fragment,
+ user: { name: "Test", color: "red" },
+ ...(withSuggestions ? { suggestionDoc, renderer } : {}),
+ },
}),
);
editor.replaceBlocks(editor.document, [
@@ -106,12 +121,16 @@ it.each([undefined, false])(
expect(
ySyncPluginKey.getState(editor.prosemirrorState)?.customCompare,
).toBe(liveBinding.customCompare);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.renderer).toBe(
+ liveBinding.renderer,
+ );
// Headless editors do not run the plugin view that hydrates the restored binding.
expect(Y.encodeStateAsUpdateV2(doc)).toEqual(remoteContent);
} finally {
mode.dispose();
editor._tiptapEditor.destroy();
doc.destroy();
+ suggestionDoc.destroy();
}
},
);
diff --git a/packages/core/src/y/extensions/Versioning.ts b/packages/core/src/y/extensions/Versioning.ts
index 3f14359cec..af3a6db377 100644
--- a/packages/core/src/y/extensions/Versioning.ts
+++ b/packages/core/src/y/extensions/Versioning.ts
@@ -43,7 +43,7 @@ export function createYVersionView<
fork.fork();
editor.exec((state, dispatch) => pauseSync(state, dispatch ?? null));
} catch (error) {
- fork.merge({ keepChanges: false });
+ fork.merge({ keepChanges: false, renderer: binding.renderer });
throw error;
}
let closed = false;
@@ -65,7 +65,7 @@ export function createYVersionView<
if (closed) {
return;
}
- fork.merge({ keepChanges: false });
+ fork.merge({ keepChanges: false, renderer: binding.renderer });
closed = true;
},
};
diff --git a/packages/core/src/y/extensions/YSync.node.test.ts b/packages/core/src/y/extensions/YSync.node.test.ts
index c7b35b18a3..2484315249 100644
--- a/packages/core/src/y/extensions/YSync.node.test.ts
+++ b/packages/core/src/y/extensions/YSync.node.test.ts
@@ -6,6 +6,236 @@ import { afterEach, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { withCollaboration } from "./index.js";
import { YSyncExtension } from "./YSync.js";
+import { LoadingExtension } from "../../extensions/Loading/Loading.js";
+import { blocksToYDoc } from "../utils.js";
+
+class Provider {
+ awareness = undefined;
+ synced = false;
+ listeners = new Set<(synced: boolean) => void>();
+
+ on(_event: "synced", listener: (synced: boolean) => void) {
+ this.listeners.add(listener);
+ }
+ off(_event: "synced", listener: (synced: boolean) => void) {
+ this.listeners.delete(listener);
+ }
+ sync(synced = true) {
+ this.synced = synced;
+ for (const listener of this.listeners) {
+ listener(synced);
+ }
+ }
+}
+
+function setup(
+ provider?: { awareness?: undefined },
+ disableExtensions: string[] = [],
+ doc = new Y.Doc(),
+) {
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ disableExtensions,
+ collaboration: {
+ fragment: doc.get("doc"),
+ provider,
+ user: { name: "Test", color: "red" },
+ },
+ }),
+ );
+ const sync = editor.getExtension(YSyncExtension)!;
+ for (const plugin of sync.prosemirrorPlugins) {
+ editor._tiptapEditor.registerPlugin(plugin);
+ }
+ cleanups.push(() => doc.destroy());
+ cleanups.push(() => editor._tiptapEditor.destroy());
+ function mount() {
+ const controller = new AbortController();
+ sync.mount({ signal: controller.signal });
+ cleanups.push(() => controller.abort());
+ return controller;
+ }
+ const controller = mount();
+ const loading = editor.getExtension(LoadingExtension)!;
+ return { editor, doc, loading, controller, mount };
+}
+
+it("waits for the first successful sync", () => {
+ const provider = new Provider();
+ const { editor, doc, loading } = setup(provider);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ expect(doc.get("doc").length).toBe(0);
+ expect(editor.isEditable).toBe(false);
+ expect(loading.store.state.size).toBe(1);
+ provider.sync(false);
+ expect(editor.isEditable).toBe(false);
+ provider.sync();
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("unlocks when content arrives without a provider sync event", () => {
+ const provider = new Provider();
+ const { editor, doc, loading } = setup(provider);
+ const cached = blocksToYDoc(
+ editor,
+ [{ type: "paragraph", content: "Cached" }],
+ "doc",
+ );
+ cleanups.push(() => cached.destroy());
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(cached));
+ expect(provider.synced).toBe(false);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("stays editable after disconnect and remount with an empty fragment", () => {
+ const provider = new Provider();
+ const { editor, doc, loading, controller, mount } = setup(provider);
+ provider.sync();
+ provider.sync(false);
+ controller.abort();
+ mount();
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("resumes waiting after remount and cleans up subscriptions on abort", () => {
+ const provider = new Provider();
+ const { editor, loading, controller, mount } = setup(provider);
+ controller.abort();
+ expect(provider.listeners.size).toBe(0);
+ expect(loading.store.state.size).toBe(0);
+ mount();
+ expect(provider.listeners.size).toBe(1);
+ expect(editor.isEditable).toBe(false);
+ expect(loading.store.state.size).toBe(1);
+ provider.sync();
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("accepts remote content after remounting while waiting for sync", () => {
+ const provider = new Provider();
+ const { editor, doc, loading, controller, mount } = setup(provider);
+ controller.abort();
+ expect(provider.listeners.size).toBe(0);
+ expect(loading.store.state.size).toBe(0);
+ const remounted = mount();
+ expect(provider.listeners.size).toBe(1);
+ expect(loading.store.state.size).toBe(1);
+ expect(editor.isEditable).toBe(false);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ const remote = blocksToYDoc(
+ editor,
+ [{ type: "paragraph", content: "Remote content" }],
+ "doc",
+ );
+ cleanups.push(() => remote.destroy());
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(remote));
+ expect(provider.synced).toBe(false);
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+ remounted.abort();
+ expect(provider.listeners.size).toBe(0);
+});
+
+it("binds cached content immediately and stays editable on offline remount", () => {
+ const schema = BlockNoteEditor.create();
+ const doc = blocksToYDoc(
+ schema,
+ [{ type: "paragraph", content: "Cached" }],
+ "doc",
+ );
+ schema._tiptapEditor.destroy();
+ const provider = new Provider();
+ const { editor, loading, controller, mount } = setup(provider, [], doc);
+ expect(provider.synced).toBe(false);
+ expect(provider.listeners.size).toBe(0);
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ controller.abort();
+ mount();
+ expect(provider.synced).toBe(false);
+ expect(provider.listeners.size).toBe(0);
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+});
+
+it("does not override application read-only mode", () => {
+ const provider = new Provider();
+ const { editor, loading } = setup(provider);
+ editor.isEditable = false;
+ provider.sync();
+ expect(editor.isEditable).toBe(false);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("does not wait for an already synced provider", () => {
+ const provider = new Provider();
+ provider.sync();
+ const { editor, loading } = setup(provider);
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+});
+
+it("syncs and remounts without a Loading extension", () => {
+ const provider = new Provider();
+ const { editor, doc, controller, mount } = setup(provider, ["loading"]);
+ expect(editor.getExtension(LoadingExtension)).toBeUndefined();
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ provider.sync();
+ provider.sync(false);
+ controller.abort();
+ expect(provider.listeners.size).toBe(0);
+ mount();
+ expect(provider.listeners.size).toBe(0);
+ expect(editor.isEditable).toBe(true);
+});
+
+it.each([undefined, { awareness: undefined }])(
+ "allows immediate editing without a sync signal: %j",
+ (provider) => {
+ const { editor, loading } = setup(provider);
+ expect(editor.isEditable).toBe(true);
+ expect(loading.store.state.size).toBe(0);
+ },
+);
+
+it("keeps other loading tokens active when sync finishes", () => {
+ const provider = new Provider();
+ const { editor, loading } = setup(provider);
+ const release = loading.acquire();
+ const releaseOther = loading.acquire();
+ provider.sync();
+ expect(loading.store.state.size).toBe(2);
+ expect(editor.isEditable).toBe(false);
+ release();
+ release();
+ expect(loading.store.state.size).toBe(1);
+ expect(editor.isEditable).toBe(false);
+ releaseOther();
+ expect(loading.store.state.size).toBe(0);
+ expect(editor.isEditable).toBe(true);
+});
const cleanups: Array<() => void> = [];
afterEach(() => {
@@ -52,11 +282,8 @@ it.each([false, true])(
// The mount hook only needs an AbortSignal. Test its real callback and
// cleanup without constructing a DOM or a ProseMirror plugin view.
const controller = new AbortController();
- const cleanup = sync.mount({ signal: controller.signal });
- cleanups.push(() => {
- controller.abort();
- cleanup?.();
- });
+ sync.mount({ signal: controller.signal });
+ cleanups.push(() => controller.abort());
expect(listeners.size).toBe(1);
const callback = [...listeners][0];
callback(true);
@@ -65,7 +292,6 @@ it.each([false, true])(
);
editor.exec((state, dispatch) => pauseSync(state, dispatch ?? null));
controller.abort();
- cleanup?.();
if (removableSyncListener) {
expect(listeners.size).toBe(0);
}
diff --git a/packages/core/src/y/extensions/YSync.test.ts b/packages/core/src/y/extensions/YSync.test.ts
new file mode 100644
index 0000000000..643f3c0845
--- /dev/null
+++ b/packages/core/src/y/extensions/YSync.test.ts
@@ -0,0 +1,108 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+import { ySyncPluginKey } from "@y/prosemirror";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { blocksToYDoc } from "../utils.js";
+import { withCollaboration } from "./index.js";
+
+class Provider {
+ awareness = undefined;
+ synced = false;
+ listeners = new Set<(synced: boolean) => void>();
+
+ on(_event: "synced", listener: (synced: boolean) => void) {
+ this.listeners.add(listener);
+ }
+ off(_event: "synced", listener: (synced: boolean) => void) {
+ this.listeners.delete(listener);
+ }
+ sync(synced = true) {
+ this.synced = synced;
+ for (const listener of this.listeners) {
+ listener(synced);
+ }
+ }
+}
+
+const cleanups: Array<() => void> = [];
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0).reverse()) {
+ cleanup();
+ }
+});
+
+function cachedDocument() {
+ const schema = BlockNoteEditor.create();
+ const doc = blocksToYDoc(
+ schema,
+ [{ type: "paragraph", content: "Cached" }],
+ "doc",
+ );
+ schema._tiptapEditor.destroy();
+ cleanups.push(() => doc.destroy());
+ return doc;
+}
+
+function setup(doc = new Y.Doc(), provider?: { awareness?: undefined }) {
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: doc.get("doc"),
+ provider,
+ user: { name: "Test", color: "red" },
+ },
+ }),
+ );
+ const host = document.createElement("div");
+ document.body.append(host);
+ editor.mount(host);
+ cleanups.push(() => {
+ editor.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ doc.destroy();
+ });
+ return { editor, doc, host };
+}
+
+function isLoading(editor: BlockNoteEditor) {
+ return editor.domElement!.classList.contains("bn-loading");
+}
+
+it("connects immediately but blocks editing until the first sync", () => {
+ const provider = new Provider();
+ const { editor, doc } = setup(undefined, provider);
+ expect(ySyncPluginKey.getState(editor.prosemirrorState)?.ytype).toBe(
+ doc.get("doc"),
+ );
+ expect(doc.get("doc").length).toBe(0);
+ expect(editor.isEditable).toBe(false);
+ expect(editor.domElement!.getAttribute("contenteditable")).toBe("false");
+ expect(isLoading(editor)).toBe(true);
+ provider.sync(false);
+ expect(editor.isEditable).toBe(false);
+ provider.sync();
+ expect(editor.isEditable).toBe(true);
+ expect(isLoading(editor)).toBe(false);
+});
+
+it("allows editing cached content without waiting for the server", () => {
+ const server = cachedDocument();
+ const local = new Y.Doc();
+ Y.applyUpdate(local, Y.encodeStateAsUpdate(server));
+ const provider = new Provider();
+ const { editor, doc } = setup(local, provider);
+ expect(editor.prosemirrorState.doc.textContent).toBe("Cached");
+ expect(editor.isEditable).toBe(true);
+ expect(isLoading(editor)).toBe(false);
+ editor.updateBlock(editor.document[0], { content: "Offline edit" });
+ expect(provider.synced).toBe(false);
+ Y.applyUpdate(server, Y.encodeStateAsUpdate(doc));
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(server));
+ provider.sync();
+ expect(doc.get("doc").length).toBe(1);
+ expect(editor.prosemirrorState.doc.textContent).toBe("Offline edit");
+ const { editor: peer } = setup(server);
+ expect(peer.prosemirrorState.doc.textContent).toBe("Offline edit");
+});
diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts
index c9b9615990..6197a60fa1 100644
--- a/packages/core/src/y/extensions/YSync.ts
+++ b/packages/core/src/y/extensions/YSync.ts
@@ -18,6 +18,7 @@ import type {
} from "../../schema/index.js";
import { CollaborationOptions } from "./index.js";
import { ForkYDocExtension } from "./ForkYDoc.js";
+import { LoadingExtension } from "../../extensions/Loading/Loading.js";
/**
* Maps a Y attribution to BlockNote's `y-attributed-*` mark attrs.
@@ -103,69 +104,80 @@ export const YSyncExtension = createExtension(
| "getAttributionMarkClassName"
>
>) => {
+ // Persist across mounts: after syncing an empty document, the fragment can
+ // still be empty and the provider can report unsynced on disconnect.
+ let ready = false;
return {
key: "ySync",
fragment: options.fragment,
- mount: ({ signal }: { signal: AbortSignal }) => {
+ mount({ signal }: { signal: AbortSignal }) {
+ // Bind early so remote content can arrive, but prevent edits to an
+ // empty document that may still be loading. Cached content is safe
+ // to edit offline without waiting for the provider.
+ const { fragment, provider } = options;
+ const synced =
+ provider && "synced" in provider ? provider.synced : undefined;
+ ready ||= fragment.length > 0 || synced !== false;
+
+ if (!ready && provider) {
+ if (!("on" in provider) || typeof provider.on !== "function") {
+ throw new Error(
+ "YSync: an unsynced provider must support sync events",
+ );
+ }
+ const loading = editor.getExtension(LoadingExtension);
+ const releaseLoading = loading?.acquire() ?? (() => {});
+
+ function onSynced(synced: boolean) {
+ if (synced !== true || ready || signal.aborted) {
+ return;
+ }
+ ready = true;
+ releaseLoading();
+ }
+ function onContent() {
+ onSynced(fragment.length > 0);
+ }
+
+ fragment.observe(onContent);
+ provider.on("synced", onSynced);
+ signal.addEventListener(
+ "abort",
+ () => {
+ fragment.unobserve(onContent);
+ if ("off" in provider && typeof provider.off === "function") {
+ provider.off("synced", onSynced);
+ }
+ releaseLoading();
+ },
+ { once: true },
+ );
+ // Content or synchronization may have arrived during setup.
+ onContent();
+ if ("synced" in provider && provider.synced === true) {
+ onSynced(true);
+ }
+ }
+
// The sync plugin reconnects an existing configuration when its view is
// recreated. Do not switch an active suggestion editor back to the
// base fragment on remount.
if (
+ signal.aborted ||
ySyncPluginKey.getState(editor.prosemirrorState)?.ytype ||
editor.getExtension(ForkYDocExtension)?.store.state.isForked
) {
return;
}
- const configure = () => {
- if (
- signal.aborted ||
- editor.getExtension(ForkYDocExtension)?.store.state.isForked
- ) {
- return;
- }
- editor.exec(
- configureYProsemirror({
- ytype: options.fragment,
- // purposefully not passing `renderer` here, this is only for syncing the main doc, not switching to suggestion mode
- // In the future, we may want view suggestion mode to be the default, and then we can decide how to indicate that through the options.
- // For now though, we are leaving suggestion mode as experimental and must be explicitly enabled through the SuggestionsExtension.
- }),
- );
- };
-
- if (
- options.provider &&
- "synced" in options.provider &&
- typeof options.provider.synced === "boolean"
- ) {
- if (options.provider["synced"]) {
- configure();
- } else if (
- "on" in options.provider &&
- typeof options.provider.on === "function"
- ) {
- const provider = options.provider;
- const onSynced = (synced: boolean) => {
- if (synced) {
- configure();
- }
- };
- options.provider.on("synced", onSynced);
- return () => {
- if ("off" in provider && typeof provider.off === "function") {
- provider.off("synced", onSynced);
- }
- };
- } else {
- throw new Error(
- "YSyncExtension: provider must have a 'synced' boolean or an 'on' method to listen for 'sync'",
- );
- }
- } else {
- configure();
- }
- return;
+ editor.exec(
+ configureYProsemirror({
+ ytype: options.fragment,
+ // purposefully not passing `renderer` here, this is only for syncing the main doc, not switching to suggestion mode
+ // In the future, we may want view suggestion mode to be the default, and then we can decide how to indicate that through the options.
+ // For now though, we are leaving suggestion mode as experimental and must be explicitly enabled through the SuggestionsExtension.
+ }),
+ );
},
prosemirrorPlugins: [
syncPlugin({
diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css
index 04b6e3c33e..14b9dd8211 100644
--- a/packages/mantine/src/blocknoteStyles.css
+++ b/packages/mantine/src/blocknoteStyles.css
@@ -1011,13 +1011,12 @@ we just don't display it in CSS instead. */
.bn-mantine .bn-snapshot-body,
.bn-mantine .bn-snapshot-date,
.bn-mantine .bn-snapshot-original-date,
-.bn-mantine .bn-snapshot-secondary-label {
+.bn-mantine .bn-snapshot-users {
font-size: 13px;
line-height: 1.3;
}
-.bn-mantine .bn-snapshot-original-date,
-.bn-mantine .bn-snapshot-secondary-label {
+.bn-mantine .bn-snapshot-original-date {
color: var(--mantine-color-dimmed);
}
@@ -1029,6 +1028,34 @@ we just don't display it in CSS instead. */
transition: opacity 0.12s ease;
}
+.bn-mantine .bn-snapshot-users {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: visible;
+ white-space: normal;
+}
+
+.bn-mantine .bn-snapshot-user {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.bn-mantine .bn-snapshot-user-color {
+ background-color: currentColor;
+ border-radius: 50%;
+ flex: none;
+ height: 8px;
+ width: 8px;
+}
+
+.bn-mantine[data-mantine-color-scheme="dark"] .bn-snapshot-user-color {
+ background-color: var(--bn-user-color-light);
+}
+
.bn-mantine .bn-snapshot:hover .bn-snapshot-menu,
.bn-mantine .bn-snapshot:focus-visible .bn-snapshot-menu,
.bn-mantine .bn-snapshot:focus-within .bn-snapshot-menu {
@@ -1068,7 +1095,6 @@ we just don't display it in CSS instead. */
.bn-mantine .bn-snapshot.selected .bn-snapshot-date,
.bn-mantine .bn-snapshot.selected .bn-snapshot-original-date,
-.bn-mantine .bn-snapshot.selected .bn-snapshot-secondary-label,
.bn-mantine .bn-snapshot.selected .bn-snapshot-menu-trigger {
color: rgba(255, 255, 255, 0.9);
}
diff --git a/packages/mantine/src/versioning/Versioning.tsx b/packages/mantine/src/versioning/Versioning.tsx
index 4a99c803ed..b37b214bc5 100644
--- a/packages/mantine/src/versioning/Versioning.tsx
+++ b/packages/mantine/src/versioning/Versioning.tsx
@@ -90,7 +90,7 @@ export const Snapshot = forwardRef<
name,
date,
restoredFrom,
- secondaryLabel,
+ users,
comparingLabel,
comparingIcon,
...rest
@@ -137,9 +137,28 @@ export const Snapshot = forwardRef<
{restoredFrom && (
{restoredFrom}
)}
- {secondaryLabel && (
- {secondaryLabel}
- )}
+ {users?.length ? (
+
+ {users.map((user, index) => (
+
+ {index > 0 && ·}
+ {typeof user !== "string" && user.color && (
+
+ )}
+ {typeof user === "string" ? user : user.username}
+
+ ))}
+
+ ) : null}
{actions && (
(typeof user === "string" ? user : user.username))
+ .join(", ");
const dateString = formatVersionDate(snapshot.createdAt);
const rowDate =
isCurrent || isStart || snapshot.name !== undefined
@@ -132,7 +135,7 @@ export function Snapshot(props: {
snapshot.name ?? placeholder,
rowDate,
comparing ? dict.versioning.comparing_to : undefined,
- secondaryLabel,
+ authorLabel,
]
.filter(Boolean)
.join(", ");
@@ -261,9 +264,9 @@ export function Snapshot(props: {
}
date={rowDate}
restoredFrom={restoredFrom}
- secondaryLabel={secondaryLabel}
+ users={users}
comparingLabel={comparing ? dict.versioning.comparing_to : undefined}
- comparingIcon={comparing ? : undefined}
+ comparingIcon={comparing ? : undefined}
/>
);
diff --git a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx
index 9e365d4037..dd856451ca 100644
--- a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx
+++ b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx
@@ -400,6 +400,74 @@ describe("VersioningSidebar", () => {
expect(rows().at(-1)!.classList.contains("comparing")).toBe(true);
});
+ it.each([false, true])(
+ "does not compare across unloaded history to pinned start, namedOnly=%s",
+ async (namedOnly) => {
+ const olderPage = deferred>();
+ const start = { id: "first", createdAt: 0, name: "Beginning" };
+ const older = { id: "older", createdAt: 500, name: "Earlier" };
+ const fake = createFakeEndpoints(undefined, true);
+ fake.setSnapshots([NAMED, AUTOMATIC, older, start]);
+ const list = vi
+ .fn["list"]>()
+ .mockResolvedValueOnce(
+ success({
+ snapshots: [NAMED, AUTOMATIC, start],
+ nextCursor: "older",
+ }),
+ )
+ .mockReturnValueOnce(olderPage.promise);
+ const editor = createEditor({ ...fake.endpoints, list });
+ render(
+
+
+ ,
+ );
+ await act(async () => {});
+ const targetIndex = namedOnly ? 1 : 2;
+ const target = namedOnly ? NAMED : AUTOMATIC;
+ await act(async () => {
+ await userEvent.click(rows()[targetIndex]!);
+ });
+ expect(viewState(mode(editor))).toMatchObject({
+ snapshotId: target.id,
+ compareToId: undefined,
+ });
+
+ // Explicit comparisons against the beginning remain available.
+ await click(
+ await openMenuItem(rows()[targetIndex]!, /Compare since beginning/),
+ );
+ expect(viewState(mode(editor))).toMatchObject({ compareToId: start.id });
+ await click(rows()[targetIndex]!);
+
+ await act(async () => {
+ const loading = mode(editor).loadMore();
+ olderPage.resolve(success({ snapshots: [older] }));
+ await loading;
+ });
+ expect(viewState(mode(editor))).toMatchObject({ compareToId: undefined });
+ await act(async () => {
+ await userEvent.click(rows()[targetIndex]!);
+ });
+ expect(viewState(mode(editor))).toMatchObject({
+ snapshotId: target.id,
+ compareToId: older.id,
+ });
+ const oldestIndex = namedOnly ? 2 : 3;
+ await act(async () => {
+ await userEvent.click(rows()[oldestIndex]!);
+ });
+ expect(viewState(mode(editor))).toMatchObject({
+ snapshotId: older.id,
+ compareToId: start.id,
+ });
+ },
+ );
+
it("automatically loads filtered history, pauses on failure, and compares only loaded predecessors", async () => {
const older = deferred>();
const listSnapshots = vi
@@ -692,7 +760,7 @@ describe("VersioningSidebar", () => {
});
await click(
- page.getByRole("button", { name: "Turn on comparison", exact: true }),
+ page.getByRole("button", { name: "Show changes", exact: true }),
);
await act(async () => {});
@@ -763,7 +831,7 @@ describe("VersioningSidebar", () => {
);
await click(
page.getByRole("button", {
- name: "Turn on comparison",
+ name: "Show changes",
exact: true,
}),
);
@@ -827,7 +895,7 @@ describe("VersioningSidebar", () => {
).toBeDefined();
expect(
page
- .getByRole("button", { name: "Turn off comparison", exact: true })
+ .getByRole("button", { name: "Hide changes", exact: true })
.element(),
).toBeDefined();
expect(editor.isEditable).toBe(false);
@@ -943,7 +1011,7 @@ describe("VersioningSidebar", () => {
expect(fake.endpoints.list).toHaveBeenCalledTimes(1);
await commit(nameInput(rows()[rowIndex]!), "Unsaved milestone", "Enter");
expect(fake.endpoints[operation]).toHaveBeenCalledTimes(2);
- expect(fake.endpoints.list).toHaveBeenCalledTimes(2);
+ expect(fake.endpoints.list).toHaveBeenCalledOnce();
expect(page.getByRole("alert").query()).toBeNull();
expect(
operation === "create"
@@ -1010,9 +1078,7 @@ describe("VersioningSidebar", () => {
page.getByText("Comparing to", { exact: true }).element(),
).toBeDefined();
expect(
- page
- .getByRole("button", { name: "Turn off comparison", exact: true })
- .element(),
+ page.getByRole("button", { name: "Hide changes", exact: true }).element(),
).toBeDefined();
await click(
@@ -1067,9 +1133,7 @@ describe("VersioningSidebar", () => {
});
expect(fake.endpoints.getContent).toHaveBeenCalled();
expect(
- page
- .getByRole("button", { name: "Turn off comparison", exact: true })
- .element(),
+ page.getByRole("button", { name: "Hide changes", exact: true }).element(),
).toBeDefined();
// Selecting the source again keeps the chronological comparison.
@@ -1362,16 +1426,21 @@ describe("VersioningSidebar", () => {
expect(rows()).toHaveLength(4);
});
- it("refreshes history after creating a checkpoint", async () => {
+ it("keeps a created checkpoint locally until history is refreshed", async () => {
const fake = createFakeEndpoints();
fake.endpoints.create.mockImplementation(async (_doc, name) => {
return success({ id: "new", createdAt: 2500, name });
});
- await setup({}, fake);
+ const { editor } = await setup({}, fake);
await commit(nameInput(rows()[0]!), "Milestone", "Enter");
expect(nameInput(rows()[0]!).value).toBe("");
+ expect(rows()).toHaveLength(4);
+ expect(fake.endpoints.list).toHaveBeenCalledOnce();
+ await act(async () => {
+ await mode(editor).loadMore();
+ });
expect(rows()).toHaveLength(3);
expect(fake.endpoints.list).toHaveBeenCalledTimes(2);
});
@@ -1427,7 +1496,7 @@ describe("VersioningSidebar", () => {
);
it("preserves a newer naming draft when a pending rename succeeds and refreshes", async () => {
- const { fake } = await setup();
+ const { fake, editor } = await setup();
await click(rows()[1]!);
const { promise: pendingRename, resolve: finishRename } =
deferred();
@@ -1448,12 +1517,20 @@ describe("VersioningSidebar", () => {
expect(document.activeElement).toBe(input);
await act(async () => finishRename());
+ expect(fake.endpoints.list).toHaveBeenCalledOnce();
+ let loading!: ReturnType["loadMore"]>;
+ await act(async () => {
+ loading = mode(editor).loadMore();
+ });
expect(fake.endpoints.list).toHaveBeenCalledTimes(2);
expect(nameInput(rows()[1]!)).toBe(input);
expect(input.value).toBe("Draft B");
expect(document.activeElement).toBe(input);
- await act(async () => releaseHistory());
+ await act(async () => {
+ releaseHistory();
+ await loading;
+ });
expect(nameInput(rows()[1]!)).toBe(input);
expect(input.value).toBe("Draft B");
expect(document.activeElement).toBe(input);
@@ -1697,22 +1774,35 @@ describe("VersioningSidebar", () => {
// Keyboard
// -------------------------------------------------------------------------
- it("moves through the list with the arrow keys and selects with Enter", async () => {
+ it("scrubs previews with arrow keys, Home and End without wrapping", async () => {
const { editor } = await setup();
- rows()[0]!.focus();
+ expect(document.activeElement).toBe(rows()[0]);
await act(async () => userEvent.keyboard("{ArrowDown}"));
expect(document.activeElement).toBe(rows()[1]);
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: NAMED.id });
+ expect(rows()[1]!.getAttribute("aria-current")).toBe("true");
await act(async () => userEvent.keyboard("{End}"));
expect(document.activeElement).toBe(rows()[2]);
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: AUTOMATIC.id });
+ await act(async () => userEvent.keyboard("{ArrowDown}"));
+ expect(document.activeElement).toBe(rows()[2]);
+ expect(rows()[2]!.getAttribute("aria-current")).toBe("true");
await act(async () => userEvent.keyboard("{Home}"));
expect(document.activeElement).toBe(rows()[0]);
+ expect(viewState(mode(editor)).mode).toBe("current");
+ await act(async () => userEvent.keyboard("{ArrowUp}"));
+ expect(document.activeElement).toBe(rows()[0]);
+ expect(rows()[0]!.getAttribute("aria-current")).toBe("true");
await act(async () => userEvent.keyboard("{ArrowDown}"));
+ await act(async () => userEvent.keyboard("{ArrowDown}{ArrowUp}"));
+ expect(document.activeElement).toBe(rows()[1]);
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: NAMED.id });
await act(async () => userEvent.keyboard("{Enter}"));
- await act(async () => {});
+ await act(async () => userEvent.keyboard(" "));
expect(viewState(mode(editor))).toEqual({
mode: "snapshot",
@@ -1721,6 +1811,79 @@ describe("VersioningSidebar", () => {
});
});
+ it("scrubs only visible named rows and retains comparison at either end", async () => {
+ const fake = createFakeEndpoints();
+ const older = { id: "older", createdAt: 500, name: "First draft" };
+ fake.setSnapshots([NAMED, AUTOMATIC, older]);
+ const { editor } = await setup(
+ { defaultNamedOnly: true, defaultComparisonMode: true },
+ fake,
+ );
+ act(() => rows()[0]!.focus());
+ await act(async () => userEvent.keyboard("{ArrowDown}"));
+ expect(document.activeElement).toBe(rows()[1]);
+ expect(viewState(mode(editor))).toEqual({
+ mode: "snapshot",
+ snapshotId: NAMED.id,
+ compareToId: older.id,
+ });
+ await act(async () => userEvent.keyboard("{ArrowDown}"));
+ expect(document.activeElement).toBe(rows()[2]);
+ expect(viewState(mode(editor))).toEqual({
+ mode: "snapshot",
+ snapshotId: older.id,
+ compareToId: undefined,
+ });
+ await act(async () => userEvent.keyboard("{ArrowUp}"));
+ expect(document.activeElement).toBe(rows()[1]);
+ expect(viewState(mode(editor))).toMatchObject({
+ snapshotId: NAMED.id,
+ compareToId: older.id,
+ });
+ });
+
+ it("keeps the latest scrub selected when older preview loads finish last", async () => {
+ const { editor, fake } = await setup();
+ const slow = deferred>();
+ fake.endpoints.getContent.mockReturnValueOnce(slow.promise);
+ act(() => rows()[0]!.focus());
+ await act(async () => userEvent.keyboard("{ArrowDown}"));
+ expect(document.activeElement).toBe(rows()[1]);
+ expect(fake.endpoints.getContent).toHaveBeenCalledWith(
+ NAMED.id,
+ expect.any(AbortSignal),
+ );
+ await act(async () => userEvent.keyboard("{ArrowDown}"));
+ expect(document.activeElement).toBe(rows()[2]);
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: AUTOMATIC.id });
+ await act(async () => slow.resolve(success([])));
+ expect(document.activeElement).toBe(rows()[2]);
+ expect(rows()[2]!.getAttribute("aria-current")).toBe("true");
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: AUTOMATIC.id });
+ });
+
+ it("leaves rename and menu keys to their controls", async () => {
+ const { editor, fake } = await setup();
+ await click(rows()[1]!);
+ const input = nameInput(rows()[1]!);
+ await click(input);
+ await act(async () =>
+ userEvent.keyboard("{ArrowDown}{ArrowUp}{Home}{End} "),
+ );
+ expect(document.activeElement).toBe(input);
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: NAMED.id });
+ await act(async () => userEvent.keyboard("{Escape}"));
+ fake.endpoints.getContent.mockClear();
+ const item = await openMenuItem(rows()[1]!, /^Restore$/);
+ await act(async () => {
+ item.focus();
+ await userEvent.keyboard("{ArrowDown}{ArrowUp}");
+ });
+ expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull();
+ expect(fake.endpoints.getContent).not.toHaveBeenCalled();
+ expect(viewState(mode(editor))).toMatchObject({ snapshotId: NAMED.id });
+ });
+
it("keeps a single tab stop for the whole list", async () => {
await setup();
@@ -1911,6 +2074,9 @@ describe("VersioningSidebar", () => {
expect(page.getByRole("list").element().contains(item)).toBe(false);
expect(editor.isWithinEditor(item)).toBe(true);
await click(item);
+ await act(async () => {
+ await mode(editor).loadMore();
+ });
expect(rows()).toHaveLength(2);
expect(document.activeElement).toBe(nextRow);
@@ -1922,13 +2088,14 @@ describe("VersioningSidebar", () => {
const { editor } = await setup();
const item = await openMenuItem(rows()[1]!, /^Delete$/);
const close = page
- .getByRole("button", { name: "Turn on comparison", exact: true })
+ .getByRole("button", { name: "Show changes", exact: true })
.element();
await act(async () => {
item.focus();
close.focus();
close.blur();
await mode(editor).remove(NAMED.id);
+ await mode(editor).loadMore();
});
expect(rows()).toHaveLength(2);
@@ -1941,6 +2108,7 @@ describe("VersioningSidebar", () => {
await act(async () => {
current.focus();
await mode(editor).remove(NAMED.id);
+ await mode(editor).loadMore();
});
expect(document.activeElement).toBe(current);
@@ -1951,8 +2119,9 @@ describe("VersioningSidebar", () => {
{ namedOnly: true, selection: "baseline" },
{ namedOnly: true, selection: "unrelated" },
{ namedOnly: false, selection: "deleted" },
+ { namedOnly: false, selection: "baseline" },
] as const)(
- "keeps selection and comparison when deletion only clears a name ($namedOnly, $selection)",
+ "keeps selection and reconciles the visible baseline when deletion only clears a name ($namedOnly, $selection)",
async ({ namedOnly, selection }) => {
const fake = createFakeEndpoints();
const older = { id: "older", createdAt: 500, name: "First draft" };
@@ -1973,6 +2142,9 @@ describe("VersioningSidebar", () => {
}
await click(await openMenuItem(rows()[1]!, /^Delete$/));
+ await act(async () => {
+ await ext.loadMore();
+ });
expect(
ext.store.state.mode === "versions"
@@ -1987,7 +2159,12 @@ describe("VersioningSidebar", () => {
? { mode: "snapshot", snapshotId: NAMED.id, compareToId: undefined }
: {
mode: "current",
- compareToId: selection === "baseline" ? older.id : undefined,
+ compareToId:
+ selection === "baseline"
+ ? namedOnly
+ ? older.id
+ : NAMED.id
+ : undefined,
},
);
expect(
@@ -2018,6 +2195,10 @@ describe("VersioningSidebar", () => {
compareToId: undefined,
});
expect(editor.isEditable).toBe(false);
+ expect(rows()).toHaveLength(3);
+ await act(async () => {
+ await ext.loadMore();
+ });
expect(rows()).toHaveLength(2);
});
@@ -2075,7 +2256,7 @@ describe("VersioningSidebar", () => {
} else {
await click(
page.getByRole("button", {
- name: comparison ? "Turn off comparison" : "Turn on comparison",
+ name: comparison ? "Hide changes" : "Show changes",
exact: true,
}),
);
@@ -2203,14 +2384,10 @@ describe("VersioningSidebar", () => {
compareToId: undefined,
});
expect(
- page
- .getByRole("button", { name: "Turn on comparison", exact: true })
- .query(),
+ page.getByRole("button", { name: "Show changes", exact: true }).query(),
).not.toBeNull();
expect(
- page
- .getByRole("button", { name: "Turn off comparison", exact: true })
- .query(),
+ page.getByRole("button", { name: "Hide changes", exact: true }).query(),
).toBeNull();
expect(rows()).toHaveLength(3);
expect(page.getByRole("status").query()).toBeNull();
@@ -2318,6 +2495,7 @@ describe("VersioningSidebar", () => {
act(() => rows()[1]!.focus());
await act(async () => {
await mode(editor).remove(NAMED.id);
+ await mode(editor).loadMore();
});
expect(document.activeElement).toBe(rows()[1]);
});
@@ -2325,7 +2503,7 @@ describe("VersioningSidebar", () => {
it("does not steal focus after a removed version has been left", async () => {
const { editor } = await setup({ onClose: () => {} });
const control = page
- .getByRole("button", { name: "Turn on comparison", exact: true })
+ .getByRole("button", { name: "Show changes", exact: true })
.element();
act(() => {
rows()[1]!.focus();
@@ -2333,18 +2511,32 @@ describe("VersioningSidebar", () => {
});
await act(async () => {
await mode(editor).remove(NAMED.id);
+ await mode(editor).loadMore();
});
expect(document.activeElement).toBe(control);
});
+ it("hides the named-version filter when storage cannot rename versions", async () => {
+ const { endpoints } = createFakeEndpoints();
+ const editor = createEditor({ ...endpoints, rename: undefined });
+ render(
+
+
+ ,
+ );
+ await act(async () => {});
+
+ expect(mode(editor).canRename).toBe(false);
+ expect(
+ page.getByRole("button", { name: "Show named versions only" }).elements(),
+ ).toHaveLength(0);
+ expect(rows()).toHaveLength(3);
+ });
+
it("gives every header control an accessible name", async () => {
await setup({ onClose: () => {} });
- for (const name of [
- "Show named versions only",
- "Turn on comparison",
- "Close",
- ]) {
+ for (const name of ["Show named versions only", "Show changes", "Close"]) {
expect(
page.getByRole("button", { name, exact: true }).element(),
).toBeDefined();
diff --git a/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx b/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx
index 00e21f3412..3ea9bc8152 100644
--- a/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx
+++ b/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx
@@ -1,5 +1,5 @@
import { type ReactNode } from "react";
-import { GoDiff } from "react-icons/go";
+import { LuFileDiff } from "react-icons/lu";
import { RiBookmarkLine, RiCloseLine } from "react-icons/ri";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -35,7 +35,7 @@ export function VersioningSidebarHeader(props: { onClose?: () => void }) {
const editor = useBlockNoteEditor();
const Components = useComponentsContext()!;
const dict = useDictionary();
- const { store, canCompare } = useVersioning();
+ const { store, canCompare, canRename } = useVersioning();
const {
comparisonMode,
setComparisonMode,
@@ -100,17 +100,19 @@ export function VersioningSidebarHeader(props: { onClose?: () => void }) {
aria-label={dict.versioning.title}
className="bn-action-toolbar bn-versioning-sidebar-header-actions"
>
-
-
-
+ {(canRename || namedOnly) && (
+
+
+
+ )}
{canCompare && (
void }) {
isSelected={comparisonMode}
onClick={toggleComparison}
>
-
+
)}
diff --git a/packages/react/src/components/Versioning/VersioningSidebarList.tsx b/packages/react/src/components/Versioning/VersioningSidebarList.tsx
index 791f2fded7..9bfb70ec18 100644
--- a/packages/react/src/components/Versioning/VersioningSidebarList.tsx
+++ b/packages/react/src/components/Versioning/VersioningSidebarList.tsx
@@ -43,6 +43,7 @@ export function VersioningSidebarList() {
state.mode === "versions" && state.history.status === "pending";
const listId = useId();
+ const initiallyFocused = useRef(false);
const focusedRowId = useRef(undefined);
const listRef = useRef(null);
const paginationRef = useRef(null);
@@ -91,6 +92,11 @@ export function VersioningSidebarList() {
}, [list.loaded, listing, listError, nextCursor, rows, versioning]);
useIsomorphicLayoutEffect(() => {
+ if (!initiallyFocused.current && rows.length > 0) {
+ initiallyFocused.current = true;
+ focusRow(0);
+ return;
+ }
if (!focusedRowId.current) {
return;
}
@@ -148,28 +154,40 @@ export function VersioningSidebarList() {
}
function handleKeyDown(event: KeyboardEvent, index: number) {
- // Text inputs (the inline rename) and the row menu handle their own keys.
- const target = event.target as HTMLElement;
- if (target.tagName === "INPUT" || target.closest(".bn-snapshot-menu")) {
+ // Descendant controls own their keys, including portaled menu items whose
+ // events still bubble through the row in React.
+ if (event.target !== event.currentTarget) {
return;
}
+ function scrubTo(destination: number) {
+ const clamped = Math.max(0, Math.min(destination, rows.length - 1));
+ if (event.repeat && clamped === index) {
+ return;
+ }
+ focusRow(clamped);
+ // Start immediately rather than awaiting a previous preview. The versioning
+ // extension cancels superseded loads so late results cannot select a row
+ // that keyboard focus has already left.
+ void run(() => previewRow(rows[clamped]!.snapshot));
+ }
+
switch (event.key) {
case "ArrowDown":
event.preventDefault();
- focusRow(index + 1);
+ scrubTo(index + 1);
break;
case "ArrowUp":
event.preventDefault();
- focusRow(index - 1);
+ scrubTo(index - 1);
break;
case "Home":
event.preventDefault();
- focusRow(0);
+ scrubTo(0);
break;
case "End":
event.preventDefault();
- focusRow(rows.length - 1);
+ scrubTo(rows.length - 1);
break;
case "Enter":
case " ":
diff --git a/packages/react/src/components/Versioning/VersioningTestComponents.browser.tsx b/packages/react/src/components/Versioning/VersioningTestComponents.browser.tsx
index 69a129eea4..2591823e92 100644
--- a/packages/react/src/components/Versioning/VersioningTestComponents.browser.tsx
+++ b/packages/react/src/components/Versioning/VersioningTestComponents.browser.tsx
@@ -230,7 +230,19 @@ function VersioningSnapshot(props: ComponentProps["Versioning"]["Snapshot"]) {
{props.name}
{props.date && {props.date}}
{props.restoredFrom && {props.restoredFrom}}
- {props.secondaryLabel && {props.secondaryLabel}}
+ {props.users?.map((user, index) => (
+
+ {index > 0 && ·}
+ {typeof user !== "string" && user.color && (
+
+ )}
+ {typeof user === "string" ? user : user.username}
+
+ ))}
{props.comparingLabel && {props.comparingLabel}}
{props.comparingIcon}
{props.actions}
diff --git a/packages/react/src/components/Versioning/usePreviewRow.ts b/packages/react/src/components/Versioning/usePreviewRow.ts
index 12d177a640..1fe9ca04e5 100644
--- a/packages/react/src/components/Versioning/usePreviewRow.ts
+++ b/packages/react/src/components/Versioning/usePreviewRow.ts
@@ -34,7 +34,7 @@ export function usePreviewRow(): (
row: VersionSnapshot,
options?: { compareTo?: CompareTarget; namedOnly?: boolean },
) => Promise {
- const { select, store } = useVersioning();
+ const { select, store, historyIncludesBeginning } = useVersioning();
const { comparisonMode, namedOnly } = useVersioningSidebar();
return useCallback(
@@ -63,6 +63,16 @@ export function usePreviewRow(): (
row,
options?.namedOnly ?? namedOnly,
)?.id;
+ // The pinned beginning is not adjacent while older pages are missing.
+ const state = store.state;
+ if (
+ historyIncludesBeginning &&
+ state.mode === "versions" &&
+ state.nextCursor !== undefined &&
+ compareToId === list.snapshots.at(-1)?.id
+ ) {
+ compareToId = undefined;
+ }
break;
}
case "snapshot":
@@ -77,6 +87,6 @@ export function usePreviewRow(): (
return select(target, { compareTo: compareToId });
},
- [store, comparisonMode, namedOnly, select],
+ [store, comparisonMode, namedOnly, select, historyIncludesBeginning],
);
}
diff --git a/packages/react/src/components/Versioning/useSnapshotUsers.ts b/packages/react/src/components/Versioning/useSnapshotUsers.ts
new file mode 100644
index 0000000000..a37b9d1cec
--- /dev/null
+++ b/packages/react/src/components/Versioning/useSnapshotUsers.ts
@@ -0,0 +1,32 @@
+import { User } from "@blocknote/core";
+import type { VersionSnapshot } from "@blocknote/core/extensions";
+import { useEffect } from "react";
+
+import { useVersioning } from "./useVersioning.js";
+import { useStore } from "../../hooks/useStore.js";
+
+/** Reads complete author records, retaining raw IDs until they resolve. */
+export function useSnapshotUsers(
+ snapshot: VersionSnapshot | undefined,
+): (User | string)[] | undefined {
+ const byIds =
+ snapshot?.by === undefined
+ ? []
+ : Array.isArray(snapshot.by)
+ ? snapshot.by
+ : [snapshot.by];
+ const { userStore } = useVersioning();
+
+ // `byIds` is often a fresh array each render, so key the effect on its
+ // contents rather than its identity to avoid re-loading on every render.
+ const byIdsKey = JSON.stringify(byIds);
+
+ useEffect(() => {
+ void userStore.loadUsers(byIds);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userStore, byIdsKey]);
+
+ return useStore(userStore.store, (users) =>
+ byIds.length === 0 ? undefined : byIds.map((id) => users.get(id) ?? id),
+ );
+}
diff --git a/packages/react/src/components/Versioning/useVersionUsers.ts b/packages/react/src/components/Versioning/useVersionUsers.ts
deleted file mode 100644
index 2cb720f336..0000000000
--- a/packages/react/src/components/Versioning/useVersionUsers.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { User } from "@blocknote/core";
-import type { VersionSnapshot } from "@blocknote/core/extensions";
-import { useEffect } from "react";
-
-import { useVersioning } from "./useVersioning.js";
-import { useStore } from "../../hooks/useStore.js";
-
-/**
- * Reads users from the versioning extension's user store, loading any that
- * aren't cached yet. Re-renders only when one of the requested users changes
- * (the store uses a shallow `Map` comparison).
- *
- * Versioning-scoped: it reads the store the versioning extension builds
- * from its `resolveUsers` option and exposes on its instance, so it must be
- * used within a versioning UI where that extension is registered.
- */
-export function useVersionUsers(
- userIds: string[],
-): Map {
- const { userStore } = useVersioning();
-
- // `userIds` is often a fresh array each render, so key the effect on its
- // contents rather than its identity to avoid re-loading on every render.
- const userIdsKey = userIds.join(",");
-
- useEffect(() => {
- void userStore.loadUsers(userIds);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [userStore, userIdsKey]);
-
- return useStore(userStore.store, (state) => {
- const users = new Map();
- for (const id of userIds) {
- const user = state.get(id) as U | undefined;
- if (user) {
- users.set(id, user);
- }
- }
- return users;
- });
-}
-
-/**
- * Computes the secondary label to display for a snapshot row: the snapshot's
- * explicit `secondaryLabel` when set, otherwise its `by` author ids resolved
- * to usernames (falling back to the raw id for users the store can't
- * resolve). Reactive: re-renders as user info loads into the store.
- */
-export function useSnapshotLabel(
- snapshot: VersionSnapshot | undefined,
-): string | undefined {
- const byIds =
- snapshot?.by === undefined
- ? []
- : Array.isArray(snapshot.by)
- ? snapshot.by
- : [snapshot.by];
- const users = useVersionUsers(byIds);
-
- if (snapshot?.secondaryLabel !== undefined) {
- return snapshot.secondaryLabel;
- }
- if (byIds.length === 0) {
- return undefined;
- }
- return byIds.map((id) => users.get(id)?.username ?? id).join(", ");
-}
diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx
index 12bd1791ca..9718382b44 100644
--- a/packages/react/src/editor/ComponentsContext.tsx
+++ b/packages/react/src/editor/ComponentsContext.tsx
@@ -321,7 +321,8 @@ export type ComponentProps = {
name: ReactNode;
date?: string;
restoredFrom?: string;
- secondaryLabel?: string;
+ /** Complete core users, or raw IDs for authors that have not resolved. */
+ users?: (User | string)[];
comparingLabel?: string;
comparingIcon?: ReactNode;
};
diff --git a/packages/react/src/hooks/useStore.ts b/packages/react/src/hooks/useStore.ts
index 394836a440..28578c9466 100644
--- a/packages/react/src/hooks/useStore.ts
+++ b/packages/react/src/hooks/useStore.ts
@@ -3,7 +3,7 @@
// See `packages/core/src/util/Store.ts` for why the store itself is vendored. This is the
// matching React binding, kept behaviourally identical to `@tanstack/react-store@0.7.7` —
// in particular the `shallow` default comparator, which `useCommentUsers` and
-// `useVersionUsers` rely on to avoid re-rendering when an unrelated user resolves.
+// `useSnapshotUsers` rely on to avoid re-rendering when an unrelated user resolves.
import type { Store } from "@blocknote/core";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
diff --git a/packages/react/src/versioning.ts b/packages/react/src/versioning.ts
index 7cc90b904e..28d4021983 100644
--- a/packages/react/src/versioning.ts
+++ b/packages/react/src/versioning.ts
@@ -1,4 +1,4 @@
-export * from "./components/Versioning/useVersionUsers.js";
+export * from "./components/Versioning/useSnapshotUsers.js";
export * from "./components/Versioning/usePreviewRow.js";
export * from "./components/Versioning/VersioningSidebar.js";
export * from "./components/Versioning/VersioningSidebarContext.js";
diff --git a/packages/shadcn/src/style.css b/packages/shadcn/src/style.css
index fd4477a024..2324e64019 100644
--- a/packages/shadcn/src/style.css
+++ b/packages/shadcn/src/style.css
@@ -182,8 +182,35 @@
border-color: rgb(255 255 255 / 0.6);
}
+.bn-shadcn .bn-snapshot-users {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: visible;
+ white-space: normal;
+}
+
+.bn-shadcn .bn-snapshot-user {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.bn-shadcn .bn-snapshot-user-color {
+ background-color: currentColor;
+ border-radius: 50%;
+ flex: none;
+ height: 8px;
+ width: 8px;
+}
+
+.bn-shadcn .bn-snapshot-user-color:where(.dark, .dark *) {
+ background-color: var(--bn-user-color-light);
+}
+
.bn-shadcn .bn-snapshot.selected .bn-snapshot-original-date,
-.bn-shadcn .bn-snapshot.selected .bn-snapshot-secondary-label,
.bn-shadcn .bn-snapshot.selected .bn-snapshot-menu-trigger {
color: color-mix(in srgb, var(--primary-foreground) 90%, transparent);
}
diff --git a/packages/shadcn/src/versioning/Versioning.tsx b/packages/shadcn/src/versioning/Versioning.tsx
index 7db3a9c115..cd2c38f435 100644
--- a/packages/shadcn/src/versioning/Versioning.tsx
+++ b/packages/shadcn/src/versioning/Versioning.tsx
@@ -94,7 +94,7 @@ export const Snapshot = forwardRef<
name,
date,
restoredFrom,
- secondaryLabel,
+ users,
comparingLabel,
comparingIcon,
...rest
@@ -144,11 +144,28 @@ export const Snapshot = forwardRef<
{restoredFrom}
)}
- {secondaryLabel && (
-
- {secondaryLabel}
+ {users?.length ? (
+
+ {users.map((user, index) => (
+
+ {index > 0 && ·}
+ {typeof user !== "string" && user.color && (
+
+ )}
+ {typeof user === "string" ? user : user.username}
+
+ ))}
- )}
+ ) : null}
{actions && (
{
const Versions = createVersioningExtension(() => ({
storage: createEndpoints(),
+ resolveUsers: async () => [
+ {
+ id: "alex",
+ username: "Alexandra Johnson",
+ avatarUrl: "",
+ color: "#c92a2a",
+ colorLight: "#ffa8a8",
+ },
+ {
+ id: "morgan",
+ username: "Morgan Williams",
+ avatarUrl: "",
+ color: "#1864ab",
+ colorLight: "#a5d8ff",
+ },
+ ],
adapter: {
supportsComparison: true,
open() {
@@ -190,12 +212,17 @@ test("renders every versioning skin in light and dark themes", async () => {
expect(
document.querySelectorAll('[role="region"][aria-label="History"]'),
).toHaveLength(6);
+ expect(document.querySelectorAll(".bn-snapshot-users")).toHaveLength(6);
+ for (const authors of document.querySelectorAll(".bn-snapshot-users")) {
+ expect(authors.textContent).toContain("Alexandra Johnson");
+ expect(authors.textContent).toContain("Morgan Williams");
+ }
});
const matrix = document.querySelector(
'[data-test="versioning-skin-matrix"]',
)!;
- await expectElement(matrix).toMatchScreenshot("versioning-skins-light-dark");
+ await screenshotFull(matrix, "versioning-skins-light-dark");
});
test.each(
@@ -301,7 +328,7 @@ test.each(["mantine", "ariakit", "shadcn"] as const)(
)!;
const rows = await waitForRows(region);
await clickElement(
- region.querySelector('button[aria-label="Turn on comparison"]')!,
+ region.querySelector('button[aria-label="Show changes"]')!,
);
await clickElement(rows[1]!);
await vi.waitFor(() =>