/ has no `.bn-block-content` and
its text sits in a bare (not `.bn-inline-content`), so neither the
strikethrough above nor the block card reaches it — and a table row/cell can't
-host the "Deleted" card anyway. Treat them like inline deletions instead: strike
-the cell text through in the author's color, and suppress the fallback badge.
+host the "Deleted" card anyway. Strike the cell text through in the author's
+color and suppress badges inside cells, including nested paragraph badges.
*/
.bn-suggestion-node--delete :is(td, th) p {
color: var(--user-color-dark);
@@ -1213,7 +1228,8 @@ the cell text through in the author's color, and suppress the fallback badge.
color: var(--user-color-light);
}
-.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before {
+.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before,
+:is(td, th) .bn-suggestion-node--delete > :first-child::before {
content: none;
}
@@ -1226,16 +1242,34 @@ spans the whole subtree; the media wrapper exists only for files), but it sets
only non-collapsing properties — background / radius / padding never depend on the
content's intrinsic size, so no block can break.
*/
+/* Attribute changes use the same card for text blocks as for media blocks. */
+[data-type="attributes"] > .bn-suggestion-node > .bn-block-content,
.bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) {
+ /* The card bleeds this far into the gutters on both sides, so tinting a block
+ never shifts its content sideways. */
+ --bn-suggestion-card-inset: 6px;
background-color: color-mix(in srgb, var(--user-color-light) 50%, white);
- border-radius: 16px;
- padding: 12px;
+ border-radius: 6px;
+ /* A hairline in the author's color, so a pale tint still reads as a card. */
+ box-shadow: 0 0 0 1px
+ color-mix(in srgb, var(--user-color-dark) 25%, transparent);
+ padding: 3px var(--bn-suggestion-card-inset);
+ margin-left: calc(-1 * var(--bn-suggestion-card-inset));
+ width: calc(100% + 2 * var(--bn-suggestion-card-inset));
}
+.dark.bn-root
+ [data-type="attributes"]
+ > .bn-suggestion-node
+ > .bn-block-content,
.dark.bn-root
.bn-suggestion-node
.bn-block-content:not(:has(.bn-inline-content)) {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
}
/*
@@ -1255,11 +1289,12 @@ gated on a wrapper that only width-bearing blocks have.
/*
A deletion additionally flags the block with the localized "Deleted" label, placed
-above the content (out of flow) with extra top padding reserving its row.
+above the content (out of flow) with extra top padding reserving its row: the
+card's own 3px, the label's 16px line box, and 2px of breathing room under it.
*/
.bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) {
position: relative;
- padding: 48px 24px 24px;
+ padding-top: calc(3px + 16px + 2px);
}
.bn-suggestion-node--delete
@@ -1268,11 +1303,13 @@ above the content (out of flow) with extra top padding reserving its row.
/* Sits in the reserved top padding, above the content. Out of flow so it never
becomes a flex item beside the block. */
position: absolute;
- top: 16px;
- left: 24px;
- font-size: 18px;
- font-weight: 500;
- line-height: 1.2;
+ top: 3px;
+ left: var(--bn-suggestion-card-inset);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
/* Use the editor's text color (themed for light/dark) rather than inheriting,
which would pick up the suggestion's user color. */
color: var(--bn-colors-editor-text);
@@ -1310,7 +1347,11 @@ left untouched so only the dotted underline carries the color. Both the inline
.dark.bn-root [data-type="modification"] .bn-suggestion-mark:hover,
.dark.bn-root [data-type="modification"] .bn-suggestion-node:hover > * {
- background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+ background-color: color-mix(
+ in srgb,
+ var(--user-color-light) 25%,
+ var(--bn-colors-editor-background)
+ );
}
/*
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index bc30d58092..783691905a 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -102,7 +102,7 @@ export interface BlockNoteEditorOptions<
dictionary?: Dictionary & Record;
/**
- * Disable internal extensions (based on keys / extension name)
+ * Disable internal extensions (based on keys / extension name).
*
* @note Advanced
*/
@@ -499,6 +499,8 @@ export class BlockNoteEditor<
const tiptapOptions: EditorOptions = {
...blockNoteTipTapOptions,
...newOptions._tiptapOptions,
+ // ReadOnlyExtension owns editability, including the initial application preference.
+ editable: true,
element: null,
autofocus: newOptions.autofocus ?? false,
extensions: tiptapExtensions,
@@ -1069,7 +1071,10 @@ export class BlockNoteEditor<
}
/**
- * Makes the editor editable or locks it, depending on the argument passed.
+ * Sets the application's editable preference. Feature read-only restrictions
+ * still apply when set to true.
+ * Plugins can temporarily prevent editing without changing this setting.
+ * The getter reports whether editing is currently allowed by both.
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css
index a1a3dda7b0..748073c66a 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -195,3 +195,64 @@ For the ShowSelectionPlugin
background-color: highlight;
padding: 2px 0;
}
+
+/* Shared loading indicator. Override the color and size on the host element. */
+.bn-loader,
+.bn-editor.bn-loading::before {
+ animation:
+ bn-loader-rotate 1s linear infinite,
+ bn-loader-clip 2s linear infinite;
+ border: calc(5 * var(--bn-loader-size, 1px)) solid
+ var(--bn-loader-color, currentColor);
+ border-radius: 50%;
+ box-sizing: border-box;
+ display: block;
+ height: calc(48 * var(--bn-loader-size, 1px));
+ width: calc(48 * var(--bn-loader-size, 1px));
+}
+
+/* Keep the editor's loader visible while scrolling without moving content. */
+.bn-editor.bn-loading > .bn-block-group {
+ opacity: 0.4;
+ transition: opacity 0.2s ease;
+}
+
+.bn-editor.bn-loading::before {
+ content: "";
+ margin: 0 auto calc(-48 * var(--bn-loader-size, 1px));
+ position: sticky;
+ top: 16px;
+ z-index: 1;
+}
+
+@keyframes bn-loader-rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes bn-loader-clip {
+ 0% {
+ clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0);
+ }
+ 25% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0);
+ }
+ 50% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%);
+ }
+ 75% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
+ }
+ 100% {
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bn-loader,
+ .bn-editor.bn-loading::before {
+ animation: none;
+ clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
+ }
+}
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 853cca2493..0eb62d9e7d 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -21,6 +21,7 @@ import {
PlaceholderExtension,
PositionMappingExtension,
PreviousBlockTypeExtension,
+ ReadOnlyExtension,
ShowSelectionExtension,
SideMenuExtension,
SourceBlockWithPreviewExtension,
@@ -168,6 +169,7 @@ export function getDefaultExtensions(
LinkToolbarExtension(options),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
+ ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
ShowSelectionExtension(options),
SideMenuExtension(options),
SourceBlockWithPreviewExtension(),
diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts
index 9dc3eebff2..c6a2edbdcb 100644
--- a/packages/core/src/editor/managers/StateManager.ts
+++ b/packages/core/src/editor/managers/StateManager.ts
@@ -1,4 +1,5 @@
import { Command, Transaction } from "prosemirror-state";
+import { ReadOnlyExtension } from "../../extensions/ReadOnly/ReadOnly.js";
import type { HistoryExtension } from "../../extensions/History/History.js";
import { BlockNoteEditor } from "../BlockNoteEditor.js";
@@ -188,13 +189,24 @@ export class StateManager {
}
return false;
}
+ if (this.editor.headless) {
+ // No live view while unmounted, so tiptap can't consult plugin props
+ // (its unmounted view stub reports editable: true). Mirror the
+ // ReadOnly plugin's `editable` prop directly so the application
+ // preference and feature restrictions still read back correctly,
+ // e.g. for static/server-side rendering via block render functions.
+ const state = this.editor.getExtension(ReadOnlyExtension)?.store.state;
+ if (state) {
+ return state.isEditable && state.enabledSet.size === 0;
+ }
+ }
return this.editor._tiptapEditor.isEditable === undefined
? true
: this.editor._tiptapEditor.isEditable;
}
/**
- * Makes the editor editable or locks it, depending on the argument passed.
+ * Sets the application's editable preference without releasing feature restrictions.
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
@@ -205,9 +217,7 @@ export class StateManager {
// not relevant on headless
return;
}
- if (this.editor._tiptapEditor.options.editable !== editable) {
- this.editor._tiptapEditor.setEditable(editable);
- }
+ this.editor.getExtension(ReadOnlyExtension)!.setEditable(editable);
}
/**
diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts
new file mode 100644
index 0000000000..6cb09d0592
--- /dev/null
+++ b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts
@@ -0,0 +1,181 @@
+/** @vitest-environment jsdom */
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vite-plus/test";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { ReadOnlyExtension } from "./ReadOnly.js";
+
+describe("ReadOnlyExtension", () => {
+ let editor: BlockNoteEditor;
+ let readOnly: ReturnType>;
+
+ beforeEach(() => {
+ editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+ readOnly = editor.getExtension(ReadOnlyExtension)!;
+ });
+
+ afterEach(() => editor.unmount());
+
+ it("keeps editing disabled until every feature releases its restriction", () => {
+ readOnly.setReadOnly(true, "preview");
+ readOnly.setReadOnly(true, "upload");
+ readOnly.setReadOnly(true, "upload");
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "preview");
+ readOnly.setReadOnly(false, "unrelated");
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("preserves the application's latest editable setting", () => {
+ readOnly.setReadOnly(true, "preview");
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = false;
+ readOnly.setReadOnly(false, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("notifies transaction subscribers without reporting document changes", () => {
+ const changes = vi.fn();
+ const editableStates: boolean[] = [];
+ editor.onChange(changes);
+ editor._tiptapEditor.on("transaction", () => {
+ editableStates.push(editor.isEditable);
+ });
+
+ readOnly.setReadOnly(true, "preview");
+ expect(editableStates.length).toBeGreaterThan(0);
+ expect(editableStates.every((editable) => !editable)).toBe(true);
+ editableStates.length = 0;
+ readOnly.setReadOnly(true, "preview");
+ expect(editableStates).toEqual([]);
+ readOnly.setReadOnly(false, "preview");
+
+ expect(editableStates.length).toBeGreaterThan(0);
+ expect(editableStates.every((editable) => editable)).toBe(true);
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it("uses editable metadata for both inputs and skips changes that keep editing locked", () => {
+ const metadata: unknown[] = [];
+ const changes = vi.fn();
+ editor.onChange(changes);
+ editor._tiptapEditor.on("transaction", ({ transaction }) => {
+ metadata.push(transaction.getMeta("editable"));
+ });
+
+ editor.isEditable = false;
+ expect(metadata.filter((value) => value !== undefined)).toEqual([true]);
+ metadata.length = 0;
+ readOnly.setReadOnly(true, "preview");
+ editor.isEditable = true;
+ readOnly.setReadOnly(true, "upload");
+ readOnly.setReadOnly(false, "preview");
+ expect(metadata).toEqual([]);
+ expect(editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(metadata.filter((value) => value !== undefined)).toEqual([true]);
+ expect(editor.isEditable).toBe(true);
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it("applies initial editability and preserves it across remounts", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } });
+ editor.mount(document.createElement("div"));
+ expect(editor.isEditable).toBe(false);
+ expect(editor.prosemirrorView.editable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ editor.isEditable = false;
+ editor.unmount();
+ editor.mount(document.createElement("div"));
+ expect(editor.prosemirrorView.editable).toBe(false);
+ });
+
+ it("reports application and feature editability while unmounted", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create();
+ readOnly = editor.getExtension(ReadOnlyExtension)!;
+
+ expect(editor.isEditable).toBe(true);
+
+ editor.isEditable = false;
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+
+ readOnly.setReadOnly(true, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = false;
+ readOnly.setReadOnly(false, "preview");
+ expect(editor.isEditable).toBe(false);
+
+ editor.isEditable = true;
+ expect(editor.isEditable).toBe(true);
+ });
+
+ it("honours editability set before mount", () => {
+ editor.unmount();
+ editor = BlockNoteEditor.create();
+ editor.isEditable = false;
+ expect(editor.isEditable).toBe(false);
+ editor.mount(document.createElement("div"));
+ expect(editor.isEditable).toBe(false);
+ expect(editor.prosemirrorView.editable).toBe(false);
+ });
+
+ it("groups application editability changes into the pending transaction", () => {
+ const transactions = vi.fn();
+ const changes = vi.fn();
+ editor._tiptapEditor.on("transaction", transactions);
+ editor.onChange(changes);
+
+ editor.transact(() => {
+ editor.isEditable = false;
+ });
+ expect(editor.isEditable).toBe(false);
+ expect(transactions).toHaveBeenCalled();
+ expect(changes).not.toHaveBeenCalled();
+
+ transactions.mockClear();
+ editor.transact((tr) => {
+ tr.insertText("hello", 1);
+ editor.isEditable = true;
+ });
+ expect(editor.isEditable).toBe(true);
+ expect(editor.prosemirrorState.doc.textContent).toContain("hello");
+ expect(transactions).toHaveBeenCalled();
+ expect(changes).toHaveBeenCalledTimes(1);
+ });
+
+ it("composes with pending document and metadata-only transactions", () => {
+ editor.transact((tr) => {
+ tr.insertText("hello", 1);
+ readOnly.setReadOnly(true, "preview");
+ });
+ expect(editor.prosemirrorState.doc.textContent).toContain("hello");
+ expect(editor.isEditable).toBe(false);
+
+ editor.transact(() => readOnly.setReadOnly(false, "preview"));
+ expect(editor.isEditable).toBe(true);
+ });
+});
diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts
new file mode 100644
index 0000000000..a15876bbb4
--- /dev/null
+++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts
@@ -0,0 +1,73 @@
+import { Plugin, PluginKey } from "prosemirror-state";
+import {
+ createExtension,
+ createStore,
+ type ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+
+const PLUGIN_KEY = new PluginKey("bn-read-only");
+
+/** Owns application editability and independent feature restrictions. */
+export const ReadOnlyExtension = createExtension(
+ ({
+ editor,
+ options,
+ }: ExtensionOptions<{ editable?: boolean } | undefined>) => {
+ const store = createStore(
+ {
+ isEditable: options?.editable ?? true,
+ enabledSet: new Set(),
+ },
+ {
+ onUpdate(state, prevState) {
+ if (
+ (state.isEditable && state.enabledSet.size === 0) ===
+ (prevState.isEditable && prevState.enabledSet.size === 0)
+ ) {
+ return;
+ }
+ if (!editor.headless) {
+ // Recompute plugin editability and notify UI subscribers without a
+ // document change. Reuse any transaction already in progress.
+ editor.transact((tr) => tr.setMeta("editable", true));
+ }
+ },
+ },
+ );
+
+ return {
+ key: "readOnly",
+ store,
+ prosemirrorPlugins: [
+ new Plugin({
+ key: PLUGIN_KEY,
+ props: {
+ editable: () =>
+ store.state.isEditable && store.state.enabledSet.size === 0,
+ },
+ }),
+ ],
+ /** Set the application's preference without releasing feature restrictions. */
+ setEditable(editable: boolean) {
+ if (store.state.isEditable === editable) {
+ return;
+ }
+ store.setState({ ...store.state, isEditable: editable });
+ },
+ /**
+ * Enable or disable read-only mode for a feature identified by key.
+ * Passing false releases only that feature's restriction; other features
+ * and the application's editor.isEditable setting still apply.
+ * Repeated calls with the same key are idempotent.
+ */
+ setReadOnly(readOnly: boolean, key: string) {
+ store.setState({
+ ...store.state,
+ enabledSet: readOnly
+ ? new Set([...store.state.enabledSet, key])
+ : new Set([...store.state.enabledSet].filter((k) => k !== key)),
+ });
+ },
+ } as const;
+ },
+);
diff --git a/packages/core/src/extensions/Versioning/Versioning.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts
index 158c152da4..97d5444c8b 100644
--- a/packages/core/src/extensions/Versioning/Versioning.test.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.test.ts
@@ -6,14 +6,28 @@ import {
beforeEach,
describe,
expect,
+ expectTypeOf,
it,
vi,
} from "vite-plus/test";
+import type { Block } from "../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { UserStoreOrResolver } from "../../user/index.js";
-import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js";
-import type { VersionSnapshot } from "./Versioning.js";
+import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+import { SCROLL_TO_FIRST_CHANGE_DELAY_MS } from "./scrollToFirstChange.js";
+import {
+ LOADING_PREVIEW_CLASS,
+ LOADING_PREVIEW_DELAY_MS,
+ VersioningExtension,
+} from "./Versioning.js";
+import type {
+ PreviewController,
+ VersioningEndpoints,
+ VersioningExtensionOptions,
+ VersioningState,
+ VersionSnapshot,
+} from "./Versioning.js";
import {
createInMemoryPreviewController,
createInMemoryVersioningEndpoints,
@@ -23,11 +37,30 @@ import {
// Helpers
// ---------------------------------------------------------------------------
-function createEditor() {
- const editor = BlockNoteEditor.create();
- const div = document.createElement("div");
- editor.mount(div);
- return editor;
+/**
+ * A mounted editor with a `VersioningExtension` registered on it — registered
+ * rather than built alongside, so the extension's own ProseMirror plugins (the
+ * read-only-while-held one) are installed. `build` receives the editor,
+ * for options that need to close over it.
+ */
+function setupWith(
+ build: (
+ editor: BlockNoteEditor,
+ ) => Pick &
+ Partial>,
+) {
+ const editor = BlockNoteEditor.create({
+ extensions: [
+ (ctx) =>
+ VersioningExtension({
+ preview: createInMemoryPreviewController(ctx.editor),
+ getCurrentDocument: () => ctx.editor.document,
+ ...build(ctx.editor),
+ })(ctx),
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ return { editor, ext: editor.getExtension(VersioningExtension)! };
}
function getEditorText(editor: BlockNoteEditor): string {
@@ -38,19 +71,50 @@ function setEditorText(editor: BlockNoteEditor, text: string) {
editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);
}
-/** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */
+/** Resolve or reject a request at an explicit point in a loading transition. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+/** Minimal version factory for versioning tests. */
function snap(
id: string,
createdAt: number,
extra?: Partial,
): VersionSnapshot {
- return { id, createdAt, updatedAt: createdAt, ...extra };
+ return { id, createdAt, ...extra };
+}
+
+/** A rest state, with the in-flight flags clear. */
+function state(overrides?: Partial): VersioningState {
+ return {
+ list: { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ ...overrides,
+ };
+}
+
+/** The loaded list, or a failure — every test that reads it has listed first. */
+function loadedList(ext: { store: { state: VersioningState } }) {
+ const { list } = ext.store.state;
+ if (!list.loaded) {
+ throw new Error("expected the version list to be loaded");
+ }
+ return list;
}
/**
* Wire up a real editor with the in-memory versioning adapter.
*
- * Returns the extension instance, the editor, and helpers to seed snapshots
+ * Returns the extension instance, the editor, and helpers to seed versions
* directly into the backend (bypassing the extension).
*/
function setup(opts?: {
@@ -58,28 +122,42 @@ function setup(opts?: {
withoutRestore?: boolean;
withoutUpdateName?: boolean;
resolveUsers?: UserStoreOrResolver;
+ scrollToFirstChange?: boolean;
}) {
- const editor = createEditor();
- setEditorText(editor, opts?.initialText ?? "initial doc");
-
const endpoints = createInMemoryVersioningEndpoints();
- const preview = createInMemoryPreviewController(editor);
-
if (opts?.withoutRestore) {
- (endpoints as any).restore = undefined;
+ endpoints.restore = undefined;
}
if (opts?.withoutUpdateName) {
- (endpoints as any).rename = undefined;
+ endpoints.rename = undefined;
}
- const ext = VersioningExtension({
- endpoints,
- preview,
- getCurrentDocument: () => editor.document,
- resolveUsers: opts?.resolveUsers,
- })({ editor });
+ // Registered on the editor rather than built beside it, so the extension's
+ // own ProseMirror plugins (the read-only-while-held one) are installed.
+ let preview!: ReturnType;
+ const editor = BlockNoteEditor.create({
+ extensions: [
+ (ctx) => {
+ preview = createInMemoryPreviewController(ctx.editor);
+ return VersioningExtension({
+ endpoints,
+ preview,
+ // Through the controller, as the real adapter does: while previewing,
+ // `editor.document` holds the previewed version, not the live one.
+ getCurrentDocument: () => preview.getLiveDocument(),
+ serializeCurrentContent: () => preview.getLiveDocument(),
+ resolveUsers: opts?.resolveUsers,
+ scrollToFirstChange: opts?.scrollToFirstChange,
+ })(ctx);
+ },
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ setEditorText(editor, opts?.initialText ?? "initial doc");
+
+ const ext = editor.getExtension(VersioningExtension)!;
- /** Seed a snapshot into the backend by capturing the current editor doc. */
+ /** Seed a version into the backend by capturing the current editor doc. */
const seed = async (text: string, name?: string) => {
// Temporarily set editor text, create via endpoints, then restore.
const savedBlocks = editor.document;
@@ -88,8 +166,8 @@ function setup(opts?: {
const snapshot = await endpoints.create!(blocks, { name });
// Restore original text.
editor.replaceBlocks(editor.document, savedBlocks);
- // Refresh the store so the extension can resolve the seeded snapshot by id
- // (preview/restore look snapshots up in the store, as the UI would after
+ // Refresh the store so the extension can resolve the seeded version by id
+ // (preview/restore look versions up in the store, as the UI would after
// listing).
await ext.list();
return snapshot;
@@ -102,15 +180,13 @@ function setup(opts?: {
// Tests
// ---------------------------------------------------------------------------
-describe("sortSnapshotsNewestFirst", () => {
- it("sorts newest-first by createdAt", () => {
- const input = [snap("a", 100), snap("b", 300), snap("c", 200)];
- const sorted = sortSnapshotsNewestFirst(input);
- expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]);
+describe("VersioningExtension", () => {
+ it("requires preview controllers to render synchronously", () => {
+ expectTypeOf<() => Promise>().not.toExtend<
+ PreviewController["enterPreview"]
+ >();
});
-});
-describe("VersioningExtension", () => {
let ctx: ReturnType;
beforeEach(() => {
@@ -122,137 +198,451 @@ describe("VersioningExtension", () => {
});
// -------------------------------------------------------------------------
- // Listing snapshots
+ // Loading state
+ // -------------------------------------------------------------------------
+
+ describe("getLoadingState", () => {
+ it("is idle when neither operation is in flight", () => {
+ expect(ctx.ext.getLoadingState(state())).toEqual({ type: "idle" });
+ });
+
+ it("reports listing when only the list is fetching", () => {
+ expect(ctx.ext.getLoadingState(state({ listing: true }))).toEqual({
+ type: "listing",
+ });
+ });
+
+ it("reports loading-preview while a preview loads, outranking listing", () => {
+ const view = { mode: "snapshot", snapshotId: "a" } as const;
+ const previewing = { type: "loading-preview", view } as const;
+ expect(ctx.ext.getLoadingState(state({ loadingView: view }))).toEqual(
+ previewing,
+ );
+ expect(
+ ctx.ext.getLoadingState(state({ listing: true, loadingView: view })),
+ ).toEqual(previewing);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Listing versions
// -------------------------------------------------------------------------
- describe("listing snapshots", () => {
+ describe("listing versions", () => {
+ it("starts unloaded and live", () => {
+ expect(ctx.ext.store.state.list).toEqual({ loaded: false });
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.ext.getLoadingState()).toEqual({ type: "idle" });
+ });
+
it("populates the store from the backend, sorted newest-first", async () => {
vi.useFakeTimers();
- // Seed snapshots with distinct timestamps directly via endpoints.
- await ctx.endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "v1" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
vi.advanceTimersByTime(1000);
- await ctx.endpoints.create!([
- {
- id: "2",
- type: "paragraph" as const,
- content: "v2" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
vi.advanceTimersByTime(1000);
- await ctx.endpoints.create!([
- {
- id: "3",
- type: "paragraph" as const,
- content: "v3" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ await ctx.endpoints.create!([], {});
const result = await ctx.ext.list();
- expect(result).toHaveLength(3);
- // Newest first: v3, v2, v1
- expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt);
- expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt);
- expect(ctx.ext.store.state.snapshots).toEqual(result);
+ expect(result.snapshots).toHaveLength(3);
+ expect(result.snapshots[0]!.createdAt).toBeGreaterThan(
+ result.snapshots[1]!.createdAt,
+ );
+ expect(result.snapshots[1]!.createdAt).toBeGreaterThan(
+ result.snapshots[2]!.createdAt,
+ );
+ expect(result.current).toBeDefined();
+ expect(ctx.ext.store.state.list).toEqual(result);
vi.useRealTimers();
});
+ it("never touches the view", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+
+ await ctx.ext.list();
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+ });
+
it("reflects backend changes on subsequent calls", async () => {
- expect(await ctx.ext.list()).toEqual([]);
-
- await ctx.endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "external" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ expect((await ctx.ext.list()).snapshots).toEqual([]);
+
+ await ctx.endpoints.create!([], {});
+
+ expect((await ctx.ext.list()).snapshots).toHaveLength(1);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Editability
+ // -------------------------------------------------------------------------
+
+ describe("editability", () => {
+ it("is read-only while previewing and editable again on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ expect(ctx.editor.isEditable).toBe(true);
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("stays read-only across preview switches", async () => {
+ const s1 = await ctx.seed("content s1");
+ const s2 = await ctx.seed("content s2");
+
+ await ctx.ext.previewSnapshot(s1.id);
+ await ctx.ext.previewSnapshot(s2.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("ignores an `isEditable` set while previewing, and honours it on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ // What a React re-render does: re-applies the host's `editable` prop.
+ ctx.editor.isEditable = true;
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("preserves a host change to read-only made during preview", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ ctx.editor.isEditable = false;
+ ctx.ext.exitPreview();
+
+ expect(ctx.editor.isEditable).toBe(false);
+ ctx.editor.isEditable = true;
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("leaves another feature's read-only restriction in place on exit", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ const readOnly = ctx.editor.getExtension(ReadOnlyExtension)!;
+ await ctx.ext.previewSnapshot(seeded.id);
+ readOnly.setReadOnly(true, "upload");
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(false);
+
+ readOnly.setReadOnly(false, "upload");
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("leaves a read-only editor read-only", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ ctx.editor.isEditable = false;
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ ctx.ext.exitPreview();
+ expect(ctx.editor.isEditable).toBe(false);
+ });
- const after = await ctx.ext.list();
- expect(after).toHaveLength(1);
+ it("can be changed from inside a transaction", async () => {
+ // A dispatch of its own in here would leave the pending transaction
+ // built on a stale state; the change has to ride along with it instead.
+ ctx.editor.transact((tr) => {
+ tr.insertText("!", 1);
+ ctx.editor.isEditable = false;
+ });
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("!initial doc");
+
+ ctx.editor.transact(() => {
+ ctx.editor.isEditable = true;
+ });
+ expect(ctx.editor.isEditable).toBe(true);
+ });
+
+ it("does not report a document change for the editability change", async () => {
+ const seeded = await ctx.seed("snapshot content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ let changes = 0;
+ ctx.editor.onChange(() => changes++);
+ ctx.ext.exitPreview();
+
+ // Exiting restores the live document through the preview controller,
+ // which is one change; becoming editable again is not another.
+ expect(changes).toBe(1);
});
});
// -------------------------------------------------------------------------
- // Creating snapshots
+ // Status
// -------------------------------------------------------------------------
- describe("creating snapshots", () => {
- it("captures the current state and adds the snapshot to the store", async () => {
+ describe("status", () => {
+ it.each(["resolve", "reject"] as const)(
+ "keeps the latest preview busy when an older request completes via %s",
+ async (outcome) => {
+ const first = await ctx.seed("first content");
+ const second = await ctx.seed("second content");
+ const firstRequest = deferred();
+ const secondRequest = deferred();
+ vi.spyOn(ctx.endpoints, "getContent")
+ .mockReturnValueOnce(firstRequest.promise)
+ .mockReturnValueOnce(secondRequest.promise);
+
+ vi.useFakeTimers();
+ try {
+ const older = ctx.ext.previewSnapshot(first.id);
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ const newer = ctx.ext.previewSnapshot(second.id);
+ const latestView = {
+ mode: "snapshot",
+ snapshotId: second.id,
+ compareToId: undefined,
+ };
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(true);
+
+ if (outcome === "reject") {
+ const failure = expect(older).rejects.toThrow("old request failed");
+ firstRequest.reject(new Error("old request failed"));
+ await failure;
+ } else {
+ firstRequest.resolve(ctx.editor.document);
+ await older;
+ }
+ expect(ctx.ext.store.state.view).toEqual(latestView);
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "loading-preview",
+ view: latestView,
+ });
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(true);
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("initial doc");
+
+ secondRequest.resolve([]);
+ await newer;
+ expect(ctx.ext.store.state.view).toEqual(latestView);
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "idle",
+ });
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+ expect(getEditorText(ctx.editor)).toBe("");
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+
+ it.each(["content", "baseline", "attributions"] as const)(
+ "stays busy until comparison %s finishes",
+ async (stage) => {
+ const gate = deferred();
+ const current = snap("current", 30);
+ const shown = snap("shown", 20);
+ const baseline = snap("baseline", 10);
+ const enterPreview = vi.fn(() => undefined);
+ const { editor, ext } = setupWith(() => ({
+ endpoints: {
+ list: async () => ({ current, snapshots: [shown, baseline] }),
+ getContent: async (snapshot) => {
+ if (
+ (stage === "content" && snapshot.id === shown.id) ||
+ (stage === "baseline" && snapshot.id === baseline.id)
+ ) {
+ await gate.promise;
+ }
+ return [];
+ },
+ getAttributions: async () => {
+ if (stage === "attributions") {
+ await gate.promise;
+ }
+ return undefined;
+ },
+ },
+ preview: { enterPreview, exitPreview: () => {} },
+ }));
+ try {
+ await ext.list();
+ const pending = ext.previewSnapshot(shown.id, {
+ compareTo: baseline.id,
+ });
+ expect(ext.getLoadingState()).toEqual({
+ type: "loading-preview",
+ view: {
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: baseline.id,
+ },
+ });
+ expect(editor.isEditable).toBe(false);
+ expect(enterPreview).not.toHaveBeenCalled();
+
+ gate.resolve();
+ await pending;
+ expect(enterPreview).toHaveBeenCalledOnce();
+ expect(ext.getLoadingState()).toEqual({ type: "idle" });
+ expect(ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: baseline.id,
+ });
+ expect(editor.isEditable).toBe(false);
+ } finally {
+ gate.resolve();
+ editor.unmount();
+ }
+ },
+ );
+
+ it.each([0, LOADING_PREVIEW_DELAY_MS])(
+ "clears pending preview loading on exit after %i ms",
+ async (elapsed) => {
+ const seeded = await ctx.seed("old content");
+ const { promise: gate, resolve: release } = deferred();
+ const getContent = ctx.endpoints.getContent;
+ ctx.endpoints.getContent = async (snapshot) => {
+ await gate;
+ return getContent(snapshot);
+ };
+
+ vi.useFakeTimers();
+ try {
+ const pending = ctx.ext.previewSnapshot(seeded.id);
+ vi.advanceTimersByTime(elapsed);
+ ctx.ext.exitPreview();
+
+ expect(ctx.ext.getLoadingState()).toEqual({
+ type: "idle",
+ });
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+ // Exiting before the delay must also cancel the scheduled indicator.
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ expect(
+ ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS),
+ ).toBe(false);
+
+ release();
+ await pending;
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(getEditorText(ctx.editor)).toBe("initial doc");
+ } finally {
+ release();
+ vi.useRealTimers();
+ }
+ },
+ );
+ });
+
+ // -------------------------------------------------------------------------
+ // Naming the current version
+ // -------------------------------------------------------------------------
+
+ describe("naming the current version", () => {
+ it("captures the current state as the current row", async () => {
setEditorText(ctx.editor, "my document content");
const snapshot = await ctx.ext.create!({ name: "Draft 1" });
expect(snapshot.name).toBe("Draft 1");
- expect(snapshot.id).toBeDefined();
- expect(ctx.ext.store.state.snapshots).toHaveLength(1);
+ expect(loadedList(ctx.ext).current).toEqual(snapshot);
+ expect(loadedList(ctx.ext).snapshots).toHaveLength(0);
- // The snapshot content should round-trip — verify by previewing.
+ // A new history session lists again and accepts the backend's shape.
+ const reopened = await ctx.ext.list();
+ expect(reopened.current.id).not.toBe(snapshot.id);
+ expect(reopened.snapshots).toContainEqual(snapshot);
+
+ // The version content should round-trip — verify by previewing.
await ctx.ext.previewSnapshot(snapshot.id);
expect(getEditorText(ctx.editor)).toBe("my document content");
});
- it("maintains newest-first order when adding to existing snapshots", async () => {
+ it("does not invent history when Current is named twice in one session", async () => {
+ const first = await ctx.ext.create!({ name: "First" });
+ const second = await ctx.ext.create!({ name: "Second" });
+
+ expect(loadedList(ctx.ext).current).toEqual(second);
+ expect(loadedList(ctx.ext).snapshots).not.toContainEqual(first);
+ });
+
+ it("maintains newest-first order", async () => {
vi.useFakeTimers();
- // Seed an older snapshot.
const old = await ctx.seed("old content", "Old");
vi.advanceTimersByTime(1000);
- // List so the store knows about the seeded snapshot.
- await ctx.ext.list();
-
const newer = await ctx.ext.create!({ name: "Newer" });
- expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id);
- expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id);
+ expect(loadedList(ctx.ext).current.id).toBe(newer.id);
+ expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(old.id);
vi.useRealTimers();
});
});
// -------------------------------------------------------------------------
- // Previewing snapshots
+ // Previewing
// -------------------------------------------------------------------------
- describe("previewing snapshots", () => {
- it("shows a snapshot and tracks it in the store", async () => {
- const snap = await ctx.seed("snapshot content");
+ describe("previewing versions", () => {
+ it("shows a version and tracks it in the view", async () => {
+ const seeded = await ctx.seed("snapshot content");
- await ctx.ext.previewSnapshot(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
- expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
expect(getEditorText(ctx.editor)).toBe("snapshot content");
});
- it("supports comparing against an older snapshot", async () => {
- const _v1 = await ctx.seed("content v1");
+ it("supports comparing against an older version", async () => {
+ const v1 = await ctx.seed("content v1");
const v2 = await ctx.seed("content v2");
// The in-memory preview controller doesn't render diffs, but the call
- // should succeed and show the primary snapshot content.
- await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id });
-
+ // should succeed and show the primary version's content.
+ await ctx.ext.previewSnapshot(v2.id, { compareTo: v1.id });
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: v2.id,
+ compareToId: v1.id,
+ });
expect(getEditorText(ctx.editor)).toBe("content v2");
});
- it("switching previews updates to the new snapshot", async () => {
+ it("switching previews updates to the new version", async () => {
const s1 = await ctx.seed("content s1");
const s2 = await ctx.seed("content s2");
@@ -260,11 +650,118 @@ describe("VersioningExtension", () => {
expect(getEditorText(ctx.editor)).toBe("content s1");
await ctx.ext.previewSnapshot(s2.id);
- expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: s2.id,
+ compareToId: undefined,
+ });
expect(getEditorText(ctx.editor)).toBe("content s2");
});
});
+ // -------------------------------------------------------------------------
+ // Scroll to first change
+ // -------------------------------------------------------------------------
+
+ describe("scroll to first change", () => {
+ /**
+ * A preview controller that stamps one attribution mark into the editor
+ * DOM, the way the real diff renderer does. It has to happen inside
+ * `enterPreview`: locking editability redraws the ProseMirror view, which
+ * strips any foreign node put there beforehand.
+ */
+ function setupScrollProbe(opts?: { scroll?: boolean; withMark?: boolean }) {
+ const { editor, ext } = setupWith((editor) => ({
+ endpoints: {
+ list: async () => ({
+ current: snap("current", 30),
+ snapshots: [snap("a", 10), snap("b", 5)],
+ }),
+ getContent: async () => [],
+ getAttributions: async () => undefined,
+ } satisfies VersioningEndpoints,
+ preview: {
+ enterPreview: () => {
+ if (opts?.withMark === false) {
+ return;
+ }
+ const mark = document.createElement("span");
+ mark.dataset["userIds"] = '["u1"]';
+ // jsdom has no layout, and the scroll skips marks without a box.
+ const content = document.createElement("span");
+ content.getBoundingClientRect = () =>
+ ({ width: 100, height: 20 }) as DOMRect;
+ mark.appendChild(content);
+ editor.domElement!.appendChild(mark);
+ },
+ exitPreview: () => {},
+ applyRestore: () => {},
+ },
+ scrollToFirstChange: opts?.scroll,
+ }));
+ return { editor, ext };
+ }
+
+ /** Wait out the delay between a preview rendering and its scroll. */
+ async function awaitScrollDelay() {
+ await new Promise((resolve) =>
+ setTimeout(resolve, SCROLL_TO_FIRST_CHANGE_DELAY_MS + 50),
+ );
+ }
+
+ // jsdom doesn't implement `scrollIntoView` at all, so this installs it
+ // rather than spying on an existing method.
+ const hadScrollIntoView = "scrollIntoView" in Element.prototype;
+ let scrollIntoView: ReturnType>;
+
+ beforeEach(() => {
+ scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ });
+
+ afterEach(() => {
+ if (!hadScrollIntoView) {
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
+ }
+ });
+
+ it("scrolls to the first attribution mark once, after a comparison", async () => {
+ const { editor, ext } = setupScrollProbe();
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+
+ editor.unmount();
+ });
+
+ it("does nothing when the preview has no attribution marks", async () => {
+ const { editor, ext } = setupScrollProbe({ withMark: false });
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ editor.unmount();
+ });
+
+ it("does nothing when disabled", async () => {
+ const { editor, ext } = setupScrollProbe({ scroll: false });
+
+ await ext.list();
+ await ext.previewSnapshot("a", { compareTo: "b" });
+ await awaitScrollDelay();
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ editor.unmount();
+ });
+ });
+
// -------------------------------------------------------------------------
// Exiting preview
// -------------------------------------------------------------------------
@@ -272,79 +769,306 @@ describe("VersioningExtension", () => {
describe("exiting preview", () => {
it("clears the preview state and restores the live document", async () => {
setEditorText(ctx.editor, "live content");
- const snap = await ctx.seed("snapshot content");
+ const seeded = await ctx.seed("snapshot content");
- await ctx.ext.previewSnapshot(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
expect(getEditorText(ctx.editor)).toBe("snapshot content");
ctx.ext.exitPreview();
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
expect(getEditorText(ctx.editor)).toBe("live content");
});
});
// -------------------------------------------------------------------------
- // Restoring snapshots
+ // The live document behind a preview
// -------------------------------------------------------------------------
- describe("restoring snapshots", () => {
- it("applies the snapshot content and exits any active preview", async () => {
- setEditorText(ctx.editor, "current doc");
- const snap = await ctx.seed("old content");
+ describe("the live document while previewing", () => {
+ it("previews the current version as the live document, not what is on screen", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("snapshot content");
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ expect(getEditorText(ctx.editor)).toBe("snapshot content");
+
+ await ctx.ext.previewCurrentVersion!();
+ expect(getEditorText(ctx.editor)).toBe("live content");
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "current",
+ compareToId: undefined,
+ });
+ });
+
+ it("names the live document, not the previewed one", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("snapshot content");
+
+ await ctx.ext.previewSnapshot(seeded.id);
+ const named = await ctx.ext.create!({ name: "named while previewing" });
- // Enter preview first, then restore.
- await ctx.ext.previewSnapshot(snap.id);
- await ctx.ext.restore!(snap.id);
+ await ctx.ext.previewSnapshot(named.id);
+ expect(getEditorText(ctx.editor)).toBe("live content");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Restoring
+ // -------------------------------------------------------------------------
+ describe("restoring versions", () => {
+ it("re-lists once after restoring", async () => {
+ const seeded = await ctx.seed("old content");
+ const list = vi.spyOn(ctx.endpoints, "list");
+ await ctx.ext.restore!(seeded.id);
+ expect(list).toHaveBeenCalledTimes(1);
+ expect(ctx.editor.isEditable).toBe(true);
expect(getEditorText(ctx.editor)).toBe("old content");
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
});
- it("picks up server-side backup snapshots after re-listing", async () => {
- const snap = await ctx.seed("original");
- await ctx.ext.list();
+ it("applies the version content and exits any active preview", async () => {
+ setEditorText(ctx.editor, "current doc");
+ const seeded = await ctx.seed("old content");
- await ctx.ext.restore!(snap.id);
+ await ctx.ext.previewSnapshot(seeded.id);
+ await ctx.ext.restore!(seeded.id);
- // The in-memory endpoints create a backup snapshot on restore.
- const updated = await ctx.ext.list();
- expect(updated.length).toBe(2);
- expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe(
- true,
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ });
+
+ it("stays read-only and in preview until the backend has answered", async () => {
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ const gate = deferred();
+ const backendRestore = ctx.endpoints.restore!;
+ ctx.endpoints.restore = vi.fn(
+ async (doc: Block[], snapshot: VersionSnapshot) => {
+ await gate.promise;
+ return backendRestore(doc, snapshot);
+ },
);
+
+ const restoring = ctx.ext.restore!(seeded.id);
+ // Mid-restore: nothing can be typed into a document about to be replaced.
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view.mode).toBe("snapshot");
+
+ gate.resolve();
+ await restoring;
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it("stays read-only until the list has been refreshed as well", async () => {
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+
+ const gate = deferred();
+ const backendList = ctx.endpoints.list;
+ ctx.endpoints.list = vi.fn(async () => {
+ await gate.promise;
+ return backendList();
+ });
+
+ const restoring = ctx.ext.restore!(seeded.id);
+ await vi.waitFor(() => expect(ctx.endpoints.list).toHaveBeenCalled());
+ // The restored content is already live, but remains read-only while
+ // the sidebar's list catches up.
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view.mode).toBe("live");
+ expect(getEditorText(ctx.editor)).toBe("old content");
+
+ gate.resolve();
+ await restoring;
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it.each([false, true])(
+ "keeps a successful restore when re-listing fails (preview: %s)",
+ async (previewing) => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("old content");
+ if (previewing) {
+ await ctx.ext.previewSnapshot(seeded.id);
+ }
+ ctx.endpoints.list = async () => {
+ throw new Error("list offline");
+ };
+
+ await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow(
+ "list offline",
+ );
+
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.ext.store.state.restoring).toBe(false);
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ },
+ );
+
+ it("leaves the user where they were when the backend rejects", async () => {
+ setEditorText(ctx.editor, "live content");
+ const seeded = await ctx.seed("old content");
+ await ctx.ext.previewSnapshot(seeded.id);
+ ctx.endpoints.restore = vi.fn(async () => {
+ throw new Error("network");
+ });
+
+ await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow("network");
+
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: seeded.id,
+ compareToId: undefined,
+ });
+ expect(getEditorText(ctx.editor)).toBe("old content");
+ });
+
+ it("picks up server-side rows after re-listing", async () => {
+ const seeded = await ctx.seed("original");
+
+ await ctx.ext.restore!(seeded.id);
+
+ const updated = loadedList(ctx.ext);
+ expect(updated.snapshots).toHaveLength(2);
+ expect(updated.current.restoredFrom).toEqual({
+ id: seeded.id,
+ createdAt: seeded.createdAt,
+ });
});
it("reports restore as unavailable when endpoint omits it", () => {
const noRestore = setup({ withoutRestore: true });
- expect(noRestore.ext.canRestore).toBe(false);
expect(noRestore.ext.restore).toBeUndefined();
noRestore.editor.unmount();
});
+
+ it("reports restore as unavailable when the preview controller can't apply it", () => {
+ const noApply = setupWith((editor) => {
+ const controller = createInMemoryPreviewController(editor);
+ return {
+ endpoints: createInMemoryVersioningEndpoints(),
+ // Delegated rather than spread: the controller's
+ // `supportsComparison` is a getter that needs the mounted editor,
+ // so spreading it during `create` would throw. The methods are
+ // closure-based, so detaching them is safe.
+ preview: {
+ enterPreview: controller.enterPreview,
+ exitPreview: controller.exitPreview,
+ get supportsComparison() {
+ return controller.supportsComparison;
+ },
+ },
+ getCurrentDocument: () => controller.getLiveDocument(),
+ serializeCurrentContent: () => controller.getLiveDocument(),
+ };
+ });
+
+ expect(noApply.ext.restore).toBeUndefined();
+ noApply.editor.unmount();
+ });
});
// -------------------------------------------------------------------------
- // Updating snapshot names
+ // Removing
// -------------------------------------------------------------------------
- describe("updating snapshot names", () => {
- it("renames a snapshot in the store and backend", async () => {
- const snap = await ctx.seed("content", "Original");
- await ctx.ext.list();
+ describe("removing versions", () => {
+ it("exits the preview when the removed version is being previewed", async () => {
+ const seeded = await ctx.seed("content");
+ await ctx.ext.previewSnapshot(seeded.id);
- await ctx.ext.rename!(snap.id, "Renamed");
+ await ctx.ext.remove!(seeded.id);
- // Store was updated optimistically.
- expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed");
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(ctx.editor.isEditable).toBe(true);
+ expect(loadedList(ctx.ext).snapshots).toHaveLength(0);
+ });
+
+ it("exits the preview when the removed version is the baseline", async () => {
+ const baseline = await ctx.seed("content v1");
+ const shown = await ctx.seed("content v2");
+ await ctx.ext.previewSnapshot(shown.id, { compareTo: baseline.id });
+
+ await ctx.ext.remove!(baseline.id);
+
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
+ expect(loadedList(ctx.ext).snapshots.map((s) => s.id)).toEqual([
+ shown.id,
+ ]);
+ });
+
+ it("keeps the preview when an unrelated version is removed", async () => {
+ const other = await ctx.seed("content v1");
+ const shown = await ctx.seed("content v2");
+ await ctx.ext.previewSnapshot(shown.id);
+
+ await ctx.ext.remove!(other.id);
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: undefined,
+ });
+ });
+
+ it("keeps the preview when the backend keeps the row", async () => {
+ // A continuous-history backend (YHub): removing a version only drops
+ // its name, and the row is still there to look at.
+ const shown = await ctx.seed("content", "named");
+ await ctx.ext.previewSnapshot(shown.id);
+ ctx.endpoints.remove = vi.fn(async (snapshot) => {
+ await ctx.endpoints.rename!(snapshot, undefined);
+ });
+
+ await ctx.ext.remove!(shown.id);
+
+ expect(ctx.ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: shown.id,
+ compareToId: undefined,
+ });
+ expect(ctx.editor.isEditable).toBe(false);
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Renaming
+ // -------------------------------------------------------------------------
+
+ describe("renaming versions", () => {
+ it("renames a version in the store and backend", async () => {
+ const seeded = await ctx.seed("content", "Original");
+
+ await ctx.ext.rename!(seeded.id, "Renamed");
+
+ // Store was patched in place.
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBe("Renamed");
// Backend was also updated (verified via list).
const list = await ctx.ext.list();
- expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed");
+ expect(list.snapshots.find((s) => s.id === seeded.id)!.name).toBe(
+ "Renamed",
+ );
+ });
+
+ it("clears the name when renamed to undefined", async () => {
+ const seeded = await ctx.seed("content", "Original");
+
+ await ctx.ext.rename!(seeded.id, undefined);
+
+ expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined();
});
it("reports name updates as unavailable when endpoint omits it", () => {
const noUpdate = setup({ withoutUpdateName: true });
- expect(noUpdate.ext.canRename).toBe(false);
expect(noUpdate.ext.rename).toBeUndefined();
noUpdate.editor.unmount();
});
@@ -379,23 +1103,23 @@ describe("VersioningExtension", () => {
});
it("passes `by` author ids through list() untouched", async () => {
- const editor = createEditor();
- const ext = VersioningExtension({
+ const { editor, ext } = setupWith(() => ({
endpoints: {
- list: async () => [snap("1", 100, { by: ["u1", "u2"] })],
+ list: async () => ({
+ current: snap("current", 200),
+ snapshots: [snap("1", 100, { by: ["u1", "u2"] })],
+ }),
getContent: async () => [],
- },
- preview: createInMemoryPreviewController(editor),
- getCurrentDocument: () => editor.document,
- })({ editor });
+ } satisfies VersioningEndpoints,
+ }));
const result = await ext.list();
// Raw ids are preserved — resolving them to user info is the view
// layer's job (via `ext.userStore`), never the extension's.
- expect(result[0]!.by).toEqual(["u1", "u2"]);
- expect(result[0]!.secondaryLabel).toBeUndefined();
- expect(ext.store.state.snapshots).toEqual(result);
+ expect(result.snapshots[0]!.by).toEqual(["u1", "u2"]);
+ expect(result.snapshots[0]!.secondaryLabel).toBeUndefined();
+ expect(ext.store.state.list).toEqual(result);
editor.unmount();
});
@@ -405,20 +1129,23 @@ describe("VersioningExtension", () => {
// End-to-end workflow
// -------------------------------------------------------------------------
- describe("workflow: create, preview with diff, then restore", () => {
+ describe("workflow: name, preview with diff, then restore", () => {
it("handles the full version-history flow", async () => {
vi.useFakeTimers();
- // 1. Create version 1.
+ // 1. Name version 1.
setEditorText(ctx.editor, "doc v1");
const v1 = await ctx.ext.create!({ name: "Version 1" });
vi.advanceTimersByTime(1000);
- // 2. Modify and create version 2.
+ // 2. Modify and name version 2.
setEditorText(ctx.editor, "doc v2");
+ // Reopening history makes the backend-confirmed v1 row available.
+ await ctx.ext.list();
const v2 = await ctx.ext.create!({ name: "Version 2" });
- expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id);
+ expect(loadedList(ctx.ext).current.id).toBe(v2.id);
+ expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(v1.id);
// 3. Preview v1 with diff comparison against v2.
await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id });
@@ -427,7 +1154,7 @@ describe("VersioningExtension", () => {
// 4. Restore v1.
await ctx.ext.restore!(v1.id);
expect(getEditorText(ctx.editor)).toBe("doc v1");
- expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ctx.ext.store.state.view).toEqual({ mode: "live" });
vi.useRealTimers();
});
diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts
index cea8566ac5..4b049c73d6 100644
--- a/packages/core/src/extensions/Versioning/Versioning.ts
+++ b/packages/core/src/extensions/Versioning/Versioning.ts
@@ -4,316 +4,29 @@ import {
createStore,
type ExtensionOptions,
} from "../../editor/BlockNoteExtension.js";
-import {
- normalizeToUserStore,
- type User,
- type UserStoreOrResolver,
-} from "../../user/index.js";
-
-/**
- * Represents a single snapshot of a document's history, including metadata and content information.
- * Snapshots are used for versioning and can be created, listed, restored, and previewed through the
- * {@link VersioningEndpoints}.
- */
-export interface VersionSnapshot {
- /**
- * The unique identifier for the snapshot. A plain string for real snapshots;
- * the {@link CURRENT_VERSION_ID} symbol for the synthetic "Current version"
- * entry (which no backend ever persists or round-trips).
- */
- id: string | typeof CURRENT_VERSION_ID;
-
- /**
- * The name of the snapshot.
- */
- name?: string;
-
- /**
- * The timestamp when the snapshot was created (unix timestamp).
- */
- createdAt: number;
-
- /**
- * The timestamp when the snapshot was last updated (unix timestamp).
- */
- updatedAt: number;
-
- /**
- * An optional secondary label for the snapshot, which can display additional information such as a custom description.
- * This is for display purposes only and is not used for any logic in the versioning system.
- *
- * For author attribution, prefer {@link by}: it holds raw user ids that the
- * view layer resolves to user info (and keeps up to date as users load).
- * When both are set, `secondaryLabel` wins.
- */
- secondaryLabel?: string;
-
- /**
- * The id(s) of the user(s) that authored this version, as raw user ids —
- * never pre-resolved to display names. The view layer resolves them via the
- * {@link VersioningExtension}'s user store (see
- * {@link VersioningExtensionOptions.resolveUsers}), reactively updating as
- * user info loads. Only used when {@link secondaryLabel} is unset.
- */
- by?: User["id"] | User["id"][];
-
- /**
- * The ID of the previous snapshot that this snapshot was restored from.
- */
- restoredFromSnapshotId?: string;
-}
-
-/**
- * Identifier for a single {@link VersionSnapshot}, either the bare id or the
- * whole reference. Tracks {@link VersionSnapshot.id}, so it also accepts the
- * {@link CURRENT_VERSION_ID} symbol.
- */
-export type VersionSnapshotIdentifier =
- | VersionSnapshot["id"]
- | Pick;
-
-/**
- * The `id` of the synthetic "Current version" entry — the live document shown at
- * the top of `list()` and set as `previewedSnapshotId` while previewing it (see
- * {@link VersioningExtension.previewCurrentVersion}).
- *
- * A `unique symbol`, not a string, so it can never clash with a real snapshot id.
- * It's client-only — never fetched via `getContent` / `getAttributions` (the row
- * is previewed live) and never serialised, so no backend round-trips it. Because
- * {@link VersionSnapshot.id} is `string | typeof CURRENT_VERSION_ID`, code that
- * needs a string form for this one row (e.g. a React `key`) derives it locally.
- */
-export const CURRENT_VERSION_ID: unique symbol = Symbol("bn-current-version");
-
-/**
- * The backend contract for versioning: **where snapshot data lives** (pure
- * storage — in-memory, `localStorage`, HTTP, …). Counterpart to
- * {@link PreviewController} (*how a snapshot is rendered*) and
- * {@link VersioningExtensionOptions} (*how the live editor is bridged in*);
- * {@link VersioningExtension} orchestrates the three.
- *
- * Type params trace the data flow:
- * @typeParam Input - Live document handle passed to {@link create} / {@link restore},
- * from {@link VersioningExtensionOptions.getCurrentDocument} (e.g. `Y.Type`, `Block[]`).
- * @typeParam Output - Serialised snapshot content from {@link getContent} /
- * {@link restore}, rendered by {@link PreviewController.enterPreview} (e.g. `Uint8Array`).
- * @typeParam Attributions - Optional diff-authorship data from {@link getAttributions},
- * also consumed by {@link PreviewController.enterPreview} (e.g. `Y.ContentMap`).
- */
-export interface VersioningEndpoints<
- Input = any,
- Output = any,
- Attributions = any,
-> {
- /**
- * List all snapshots for this document, sorted newest-first by
- * {@link VersionSnapshot.createdAt}.
- */
- list: () => Promise;
- /**
- * Create a new snapshot from the current content.
- *
- * @note omit for backends with continuous history (e.g. YHub's activity
- * timeline). Gates the extension's `canCreate` flag.
- */
- create?: (
- /** Live document to snapshot, from {@link VersioningExtensionOptions.getCurrentDocument}. */
- content: Input,
- options?: {
- /** Optional name for this snapshot. */
- name?: string;
- /** Id of the snapshot this one was restored from, if any. */
- restoredFromSnapshot?: VersionSnapshot;
- },
- ) => Promise;
- /**
- * Restore the document to a snapshot. Implementations should create any backup
- * snapshots they need before returning.
- *
- * @returns The restored content ({@link Output}, **not `void`**) — passed to
- * {@link PreviewController.applyRestore}.
- * @note omit to disable restore. Gates the extension's `canRestore` flag.
- */
- restore?: (
- /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument} (for backup). */
- doc: Input,
- /** The snapshot to restore. */
- snapshot: VersionSnapshot,
- ) => Promise;
- /**
- * Fetch a snapshot's content ({@link Output}) for preview — same format as
- * {@link VersioningExtensionOptions.serializeCurrentContent}. Sibling of
- * {@link getAttributions}; both are the storage-side fetch that
- * {@link PreviewController.enterPreview} renders.
- */
- getContent: (snapshot: VersionSnapshot) => Promise;
- /**
- * Fetch diff-authorship data ({@link Attributions}: who/when) for the range
- * `compareTo → snapshot`, rendered by {@link PreviewController.enterPreview}
- * (its only consumer). Lives on the endpoint, not `enterPreview`, so one
- * preview controller pairs with attribution-capable (YHub) or attribution-less
- * (`localStorage`) backends — {@link Attributions} is that seam.
- *
- * @note omit and previews still render the content diff, minus attribution.
- */
- getAttributions?: (
- /** The previewed snapshot (the "new" side of the diff). */
- snapshot: VersionSnapshot,
- /** The baseline it's diffed against (the "old" side). */
- compareTo?: VersionSnapshot,
- ) => Promise;
- /**
- * Rename a snapshot.
- *
- * @note omit to disable rename. Gates the extension's `canRename` flag.
- */
- rename?: (snapshot: VersionSnapshot, name?: string) => Promise;
- /**
- * Permanently remove a snapshot.
- *
- * @note omit for immutable-history backends (e.g. YHub). Gates the extension's
- * `canRemove` flag.
- */
- remove?: (snapshot: VersionSnapshot) => Promise;
-}
+import { normalizeToUserStore } from "../../user/index.js";
+import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
+import { createVersioningCommands } from "./commands.js";
+import { createListSession } from "./list.js";
+import { createPreviewSession } from "./preview.js";
+import { findSnapshot, isReadOnly } from "./state.js";
+import type {
+ VersioningExtensionOptions,
+ VersioningLoadingState,
+ VersioningState,
+ VersionSnapshotIdentifier,
+} from "./types.js";
+
+export { LOADING_PREVIEW_CLASS, LOADING_PREVIEW_DELAY_MS } from "./preview.js";
+export type * from "./types.js";
/**
- * A factory function for the endpoints to receive a reference to the editor.
- *
- * @typeParam Input - See {@link VersioningEndpoints}.
- * @typeParam Output - See {@link VersioningEndpoints}.
- * @typeParam Attributions - See {@link VersioningEndpoints}.
+ * The composition root: resolves options, creates the store, wires the three
+ * sessions (list, preview, commands) together, and exposes the extension
+ * facade. Each store field has exactly one writer — `list`/`listing` the list
+ * session, `view`/`loadingView` the preview session, `restoring` the commands
+ * — and the busy status is read through from those flags by `getLoadingState`.
*/
-export type VersioningEndpointsFactory<
- Input = any,
- Output = any,
- Attributions = any,
-> = (
- editor: BlockNoteEditor,
-) => VersioningEndpoints ;
-
-/**
- * Controls **how a snapshot is rendered** — the render-side counterpart to
- * {@link VersioningEndpoints} (storage). {@link VersioningExtension} fetches
- * content/attributions from the endpoints and delegates rendering here; keeping
- * the two separate lets one controller pair with different backends.
- *
- * @typeParam Output - Serialised snapshot content; matches the endpoints' `Output`.
- * @typeParam Attributions - Optional attribution data; matches the endpoints' `Attributions`.
- */
-export interface PreviewController {
- /**
- * Whether {@link enterPreview} can render a diff (uses `compareToContent`).
- * Defaults to `true`; `false` for show-one-version-only backends (e.g. the Yjs
- * v13 adapter). Surfaced as {@link VersioningExtension.canCompare}.
- */
- supportsComparison?: boolean;
- /**
- * Enter preview mode. Arguments come from the endpoints:
- * {@link VersioningEndpoints.getContent} (content) and
- * {@link VersioningEndpoints.getAttributions} (attributions).
- */
- enterPreview: (
- /** Snapshot to preview ({@link Output}, from {@link VersioningEndpoints.getContent}). */
- snapshotContent: Output,
- /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */
- compareToContent?: Output,
- /**
- * Diff attributions ({@link Attributions}, from
- * {@link VersioningEndpoints.getAttributions}). Only meaningful with
- * `compareToContent`.
- */
- attributions?: Attributions,
- /**
- * The snapshot(s) this preview is for (metadata only — the content is
- * `snapshotContent` / `compareToContent`). Lets a controller label the
- * preview with e.g. the version's name, without smuggling it through the
- * {@link Attributions} channel. `snapshot` is the previewed version (the
- * {@link CURRENT_VERSION_ID} entry when previewing the live document);
- * `compareTo` is the baseline it's diffed against, if any.
- */
- context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot },
- ) => void;
- /** Exit preview mode and resume normal editing. */
- exitPreview: () => void;
- /**
- * Apply restored content to the live document. Called with the {@link Output}
- * from {@link VersioningEndpoints.restore}, after preview mode has exited.
- */
- applyRestore: (snapshotContent: Output) => void;
-}
-
-/** Sort snapshots newest-first by creation time. */
-export function sortSnapshotsNewestFirst(
- snapshots: VersionSnapshot[],
-): VersionSnapshot[] {
- return [...snapshots].sort((a, b) => b.createdAt - a.createdAt);
-}
-
-/**
- * Options accepted by the {@link VersioningExtension} — **how the live editor is
- * bridged in**, alongside the {@link VersioningEndpoints} (storage) and
- * {@link PreviewController} (rendering).
- *
- * @typeParam Input - See {@link VersioningEndpoints}.
- * @typeParam Output - See {@link VersioningEndpoints}.
- * @typeParam Attributions - See {@link VersioningEndpoints}.
- */
-export type VersioningExtensionOptions<
- Input = any,
- Output = any,
- Attributions = any,
-> = {
- /**
- * Backend storage for snapshots.
- */
- endpoints:
- | VersioningEndpoints
- | VersioningEndpointsFactory ;
- /**
- * Controls how snapshot previews and restores are rendered in the editor.
- */
- preview: PreviewController;
- /**
- * The **live, mutable document handle** ({@link Input}) the backend snapshots
- * *from* / restores *into*. Passed to {@link VersioningEndpoints.create} and
- * {@link VersioningEndpoints.restore}. Cf. {@link serializeCurrentContent} (a
- * detached copy); the two coincide for some backends (in-memory:
- * `Input === Output === Block[]`) and differ for others (Yjs: `Y.Type` vs `Uint8Array`).
- */
- getCurrentDocument: () => Input;
- /**
- * The live document **serialised to snapshot format** ({@link Output}, matching
- * {@link VersioningEndpoints.getContent}), for diffing the live doc against a
- * snapshot (see {@link VersioningExtension.previewCurrentVersion}). Cf.
- * {@link getCurrentDocument} (the live handle).
- *
- * @note omit and the UI can't offer a "Current version" diff. Gates the
- * extension's `canPreviewCurrent` flag.
- */
- serializeCurrentContent?: () => Output | Promise;
- /**
- * Resolve user information for the author ids in {@link VersionSnapshot.by},
- * used by the view layer to render version-author labels.
- *
- * Either a resolver function (called with the ids of users that are not yet
- * cached, returning their information — a user store is built from it
- * internally) or a pre-built user store (see `createUserStore`). Pass the
- * same store you give the comments/collaboration extensions so a single
- * de-duped user cache is shared across features.
- *
- * @note omit and author ids are displayed as-is.
- */
- resolveUsers?: UserStoreOrResolver;
-};
-
-function snapshotNotFoundError(
- id: VersionSnapshotIdentifier | undefined,
-): never {
- const idResolved = typeof id === "object" ? id.id : id;
- throw new Error(`Snapshot not found: ${String(idResolved)}`);
-}
-
export const VersioningExtension = createExtension(
({
options: optionsOrFactory,
@@ -328,179 +41,80 @@ export const VersioningExtension = createExtension(
getCurrentDocument,
serializeCurrentContent,
resolveUsers,
+ scrollToFirstChange: scrollToFirstChangeEnabled = true,
} = typeof optionsOrFactory === "function"
? optionsOrFactory(editor)
: optionsOrFactory;
const endpoints =
typeof endpointsRaw === "function" ? endpointsRaw(editor) : endpointsRaw;
+ // Capture the controller method so the restore branch has a callable type.
+ const applyRestore = preview.applyRestore?.bind(preview);
// With no resolver this is an empty store: `getUser` always misses, so the
// view layer falls back to showing the raw ids from `VersionSnapshot.by`.
const userStore = normalizeToUserStore(resolveUsers);
- const store = createStore<{
- snapshots: VersionSnapshot[];
- /**
- * The id of the version currently shown in the editor (the "new" side of
- * a diff). `undefined` means the live, editable document. Is the
- * {@link CURRENT_VERSION_ID} symbol when previewing the live document as a
- * read-only diff against a snapshot.
- */
- previewedSnapshotId?: string | typeof CURRENT_VERSION_ID;
- /**
- * The id of the snapshot the preview is being diffed against (the
- * "baseline" / old side). `undefined` when not showing a diff. Always a
- * real snapshot id (never the current entry), but typed as the same union
- * as {@link VersionSnapshot.id} since it's copied from one. Used to render
- * the "Comparing to" indicator in the sidebar.
- */
- compareToSnapshotId?: string | typeof CURRENT_VERSION_ID;
- }>({
- snapshots: [],
- previewedSnapshotId: undefined,
- compareToSnapshotId: undefined,
- });
-
- const getSnapshot = (id: VersionSnapshotIdentifier | undefined) => {
- const idResolved = typeof id === "object" ? id.id : id;
- return store.state.snapshots.find(
- (snapshot) => snapshot.id === idResolved,
- );
- };
-
- const updateSnapshots = async () => {
- const snapshots = sortSnapshotsNewestFirst(await endpoints.list());
- store.setState((state) => ({
- ...state,
- snapshots,
- }));
-
- return snapshots;
- };
-
- const previewSnapshot = async (
- id: VersionSnapshotIdentifier,
- previewOptions?: {
- /**
- * When set, the preview shows a diff against this snapshot (typically the
- * chronologically previous version in the history list).
- */
- compareTo?: VersionSnapshotIdentifier;
+ const store = createStore(
+ {
+ list: { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
},
- ) => {
- const snapshot = getSnapshot(id);
-
- if (!snapshot) {
- snapshotNotFoundError(id);
- }
-
- const compareToSnapshot = previewOptions?.compareTo
- ? getSnapshot(previewOptions.compareTo)
- : undefined;
-
- store.setState((state) => ({
- ...state,
- previewedSnapshotId: snapshot.id,
- compareToSnapshotId: compareToSnapshot?.id,
- }));
-
- let compareToContent: unknown;
- let attributions: unknown;
- if (compareToSnapshot) {
- compareToContent = await endpoints.getContent(compareToSnapshot);
- // Attributions describe the diff between the baseline and this
- // snapshot, so they're only meaningful when comparing against another
- // version. Fetching them is optional: previews still render the content
- // diff without author/timestamp information when unavailable.
- if (endpoints.getAttributions) {
- attributions = await endpoints.getAttributions(
- snapshot,
- compareToSnapshot,
- );
- }
- }
-
- const snapshotContent = await endpoints.getContent(snapshot);
- preview.enterPreview(snapshotContent, compareToContent, attributions, {
- snapshot,
- compareTo: compareToSnapshot,
- });
- };
-
- /**
- * Preview the live ("current") document as a read-only diff against a
- * snapshot baseline. Unlike {@link previewSnapshot}, the "new" side of the
- * diff is the live document — serialised via `serializeCurrentContent` —
- * rather than a stored snapshot. The editor becomes non-editable while
- * previewing (editing is gated on `previewedSnapshotId === undefined`).
- */
- const previewCurrentVersion = async (previewOptions?: {
- /**
- * The snapshot to diff the live document against (the baseline). When
- * omitted, the live document is shown without a diff.
- */
- compareTo?: VersionSnapshotIdentifier;
- }) => {
- if (!serializeCurrentContent) {
- throw new Error(
- "previewCurrentVersion requires `serializeCurrentContent` to be " +
- "provided to the VersioningExtension options.",
- );
- }
-
- const compareToSnapshot = previewOptions?.compareTo
- ? getSnapshot(previewOptions.compareTo)
- : undefined;
-
- store.setState((state) => ({
- ...state,
- previewedSnapshotId: CURRENT_VERSION_ID,
- compareToSnapshotId: compareToSnapshot?.id,
- }));
-
- // Synthesise a snapshot for the live document so timestamp-based backends
- // (e.g. YHub) resolve the changeset window up to "now", and so the preview
- // controller gets a snapshot to key off. The id is the current-version
- // sentinel; backends ignore it and resolve the window from `createdAt`.
- const currentSnapshot: VersionSnapshot = {
- id: CURRENT_VERSION_ID,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- };
-
- let compareToContent: unknown;
- let attributions: unknown;
- if (compareToSnapshot) {
- compareToContent = await endpoints.getContent(compareToSnapshot);
- if (endpoints.getAttributions) {
- attributions = await endpoints.getAttributions(
- currentSnapshot,
- compareToSnapshot,
- );
- }
- }
-
- const currentContent = await serializeCurrentContent();
- preview.enterPreview(currentContent, compareToContent, attributions, {
- snapshot: currentSnapshot,
- compareTo: compareToSnapshot,
- });
- };
+ {
+ // Sync the ReadOnly gate with the new state. Writing through the
+ // store keeps this in one place, including for external `setState`.
+ onUpdate(state, prevState) {
+ if (isReadOnly(state) !== isReadOnly(prevState)) {
+ editor
+ .getExtension(ReadOnlyExtension)!
+ .setReadOnly(isReadOnly(state), "versioning");
+ }
+ },
+ },
+ );
- const exitPreview = () => {
- store.setState((state) => ({
- ...state,
- previewedSnapshotId: undefined,
- compareToSnapshotId: undefined,
- }));
- preview.exitPreview();
- };
+ const listSession = createListSession({ store, endpoints });
+ const previewSession = createPreviewSession({
+ store,
+ endpoints,
+ preview,
+ serializeCurrentContent,
+ editor,
+ scrollToFirstChangeEnabled,
+ });
+ const commands = createVersioningCommands({
+ store,
+ endpoints,
+ getCurrentDocument,
+ applyRestore,
+ refreshList: listSession.refresh,
+ exitPreview: previewSession.exitPreview,
+ });
return {
key: "versioning",
store,
userStore,
- list: async (): Promise => {
- return await updateSnapshots();
+ /** Open history: fetch its list from the backend. */
+ list: listSession.refresh,
+ getSnapshot: (id: VersionSnapshotIdentifier) =>
+ findSnapshot(store.state.list, id),
+ /**
+ * The busy status the sidebar shows, read through from the two in-flight
+ * flags the sessions publish. Preview loading outranks listing: a fetch
+ * is the more urgent thing to communicate, and reverting to `listing`
+ * when it settles keeps a slow list request visible.
+ *
+ * Defaults to this store's state, so it doubles as a store selector when
+ * the caller passes the selected state.
+ */
+ getLoadingState: (
+ state: VersioningState = store.state,
+ ): VersioningLoadingState => {
+ if (state.loadingView) {
+ return { type: "loading-preview", view: state.loadingView };
+ }
+ return state.listing ? { type: "listing" } : { type: "idle" };
},
// Comparison is only offered when the preview controller can actually
// render a diff (see PreviewController.supportsComparison). A getter so a
@@ -510,119 +124,19 @@ export const VersioningExtension = createExtension(
get canCompare() {
return preview.supportsComparison !== false;
},
- canCreate: endpoints.create !== undefined,
- create: endpoints.create
- ? async (options?: {
- /**
- * The optional name for this snapshot.
- */
- name?: string;
- /**
- * The ID of the snapshot this one was restored from, if applicable.
- */
- restoredFromSnapshot?: VersionSnapshotIdentifier;
- }): Promise => {
- const snapshot = await endpoints.create!(getCurrentDocument(), {
- name: options?.name,
- restoredFromSnapshot: getSnapshot(options?.restoredFromSnapshot),
- });
- // Show the new version immediately. Some backends (e.g. YHub) build
- // their version list from an activity timeline that lags a beat
- // behind the create, so waiting on a re-list would leave the UI
- // briefly stale.
- store.setState((state) => ({
- ...state,
- snapshots: sortSnapshotsNewestFirst([
- ...state.snapshots,
- snapshot,
- ]),
- }));
- // Reconcile with the backend's `list()` — it owns the "current
- // version" entry and any server-assigned metadata. If the refreshed
- // list doesn't include the just-created version yet (indexing lag),
- // keep the optimistic entry so it never flickers out.
- const listed = await endpoints.list();
- store.setState((state) => ({
- ...state,
- snapshots: sortSnapshotsNewestFirst(
- listed.some((s) => s.id === snapshot.id)
- ? listed
- : [...listed, snapshot],
- ),
- }));
- return snapshot;
- }
- : undefined,
- canRestore: endpoints.restore !== undefined,
- restore: endpoints.restore
- ? async (id: VersionSnapshotIdentifier) => {
- exitPreview();
- const snapshot = getSnapshot(id);
-
- if (!snapshot) {
- snapshotNotFoundError(id);
- }
- const snapshotContent = await endpoints.restore!(
- getCurrentDocument(),
- snapshot,
- );
- preview.applyRestore(snapshotContent);
- await updateSnapshots();
- return snapshotContent;
- }
- : undefined,
- canRename: endpoints.rename !== undefined,
- rename: endpoints.rename
- ? async (
- id: VersionSnapshotIdentifier,
- name?: string,
- ): Promise => {
- const snapshot = getSnapshot(id);
- if (!snapshot) {
- snapshotNotFoundError(id);
- }
- await endpoints.rename!(snapshot, name);
- store.setState((state) => ({
- ...state,
- snapshots: state.snapshots.map((s) =>
- s.id === id ? { ...s, name, updatedAt: Date.now() } : s,
- ),
- }));
- }
- : undefined,
- canRemove: endpoints.remove !== undefined,
- remove: endpoints.remove
- ? async (id: VersionSnapshotIdentifier): Promise => {
- const snapshot = getSnapshot(id);
- if (!snapshot) {
- snapshotNotFoundError(id);
- }
- // If the snapshot being removed is the one currently previewed, or
- // the baseline it's being diffed against, exit preview first so the
- // editor returns to the live document instead of showing (or
- // comparing against) a version that no longer exists.
- if (
- store.state.previewedSnapshotId === snapshot.id ||
- store.state.compareToSnapshotId === snapshot.id
- ) {
- exitPreview();
- }
- await endpoints.remove!(snapshot);
- // Remove it optimistically so the row disappears immediately, then
- // reconcile with the backend's authoritative list.
- store.setState((state) => ({
- ...state,
- snapshots: state.snapshots.filter((s) => s.id !== snapshot.id),
- }));
- await updateSnapshots();
- }
- : undefined,
- previewSnapshot,
- canPreviewCurrent: serializeCurrentContent !== undefined,
- previewCurrentVersion: serializeCurrentContent
- ? previewCurrentVersion
- : undefined,
- exitPreview,
+ create: commands.create,
+ restore: commands.restore,
+ rename: commands.rename,
+ remove: commands.remove,
+ previewSnapshot: previewSession.previewSnapshot,
+ previewCurrentVersion: previewSession.previewCurrentVersion,
+ exitPreview: previewSession.exitPreview,
+ /**
+ * Scroll the first change of the rendered diff into view. Runs
+ * automatically after preview unless disabled; exposed for hosts.
+ * @returns whether a change was found.
+ */
+ scrollToFirstChange: previewSession.scrollToFirstChange,
} as const;
},
);
diff --git a/packages/core/src/extensions/Versioning/commands.ts b/packages/core/src/extensions/Versioning/commands.ts
new file mode 100644
index 0000000000..fb5df6cf96
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/commands.ts
@@ -0,0 +1,142 @@
+import type { Store } from "../../util/Store.js";
+import { findSnapshot } from "./state.js";
+import type {
+ VersioningEndpoints,
+ VersionSnapshotIdentifier,
+ VersioningState,
+ VersionSnapshot,
+} from "./types.js";
+
+/**
+ * The mutation commands: create, restore, rename and remove. Each composes a
+ * backend call with a list refresh and, where the result affects the screen,
+ * an exit from preview. They know nothing about supersession or status — that
+ * is the preview session's and the root's business.
+ */
+export function createVersioningCommands({
+ store,
+ endpoints,
+ getCurrentDocument,
+ applyRestore,
+ refreshList,
+ exitPreview,
+}: {
+ store: Store;
+ endpoints: VersioningEndpoints;
+ getCurrentDocument: () => any;
+ applyRestore?: (content: any) => void;
+ refreshList: () => Promise;
+ exitPreview: () => void;
+}) {
+ return {
+ create: endpoints.create
+ ? async (options?: { name?: string }): Promise => {
+ const snapshot = await endpoints.create!(getCurrentDocument(), {
+ name: options?.name,
+ });
+ // Naming does not advance the frozen history shown by the sidebar.
+ // Reopening the sidebar lists again and replaces this session-local
+ // view with the backend's authoritative rows.
+ if (!store.state.list.loaded) {
+ await refreshList();
+ }
+ store.setState((state) =>
+ state.list.loaded
+ ? {
+ ...state,
+ list: {
+ loaded: true,
+ current: snapshot,
+ snapshots: state.list.snapshots.filter(
+ (stored) => stored.id !== snapshot.id,
+ ),
+ },
+ }
+ : state,
+ );
+ return snapshot;
+ }
+ : undefined,
+ restore:
+ endpoints.restore && applyRestore
+ ? async (id: VersionSnapshotIdentifier) => {
+ const snapshot = findSnapshot(store.state.list, id);
+ if (snapshot === undefined) {
+ throw new Error(
+ `Snapshot not found: ${typeof id === "object" ? id.id : id}`,
+ );
+ }
+ // Prevent edits while the live document is about to be replaced.
+ store.setState((state) => ({ ...state, restoring: true }));
+ try {
+ const snapshotContent = await endpoints.restore!(
+ getCurrentDocument(),
+ snapshot,
+ );
+ exitPreview();
+ applyRestore(snapshotContent);
+ // Re-list so the sidebar reflects the restore. Backends whose
+ // history settles asynchronously may need a reopen to show
+ // the newest rows.
+ await refreshList();
+ return snapshotContent;
+ } finally {
+ store.setState((state) => ({ ...state, restoring: false }));
+ }
+ }
+ : undefined,
+ rename: endpoints.rename
+ ? async (id: VersionSnapshotIdentifier, name?: string): Promise => {
+ const snapshot = findSnapshot(store.state.list, id);
+ if (snapshot === undefined) {
+ throw new Error(
+ `Snapshot not found: ${typeof id === "object" ? id.id : id}`,
+ );
+ }
+ await endpoints.rename!(snapshot, name);
+ // Patch the name in place: a rename changes nothing else about the
+ // list, so re-listing would only cost a round-trip and a flicker.
+ store.setState((state) => {
+ if (!state.list.loaded) {
+ return state;
+ }
+ const patch = (s: VersionSnapshot) =>
+ s.id === snapshot.id ? { ...s, name } : s;
+ return {
+ ...state,
+ list: {
+ loaded: true,
+ current: patch(state.list.current),
+ snapshots: state.list.snapshots.map(patch),
+ },
+ };
+ });
+ }
+ : undefined,
+ remove: endpoints.remove
+ ? async (id: VersionSnapshotIdentifier): Promise => {
+ const snapshot = findSnapshot(store.state.list, id);
+ if (snapshot === undefined) {
+ throw new Error(
+ `Snapshot not found: ${typeof id === "object" ? id.id : id}`,
+ );
+ }
+ await endpoints.remove!(snapshot);
+ await refreshList();
+ // The removed row may survive as unnamed history; leave only if
+ // what is on screen (or what it is diffed against) is really gone
+ // (`exitPreview` no-ops when live).
+ const { view } = store.state;
+ const gone = (shown: string | undefined) =>
+ shown !== undefined && !findSnapshot(store.state.list, shown);
+ if (
+ view.mode !== "live" &&
+ (gone(view.mode === "snapshot" ? view.snapshotId : undefined) ||
+ gone(view.compareToId))
+ ) {
+ exitPreview();
+ }
+ }
+ : undefined,
+ };
+}
diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
index 8d9c7567eb..dc516a6aa0 100644
--- a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
+++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts
@@ -11,8 +11,9 @@ import {
} from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { en } from "../../i18n/locales/en.js";
import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js";
-import { CURRENT_VERSION_ID, VersioningExtension } from "./Versioning.js";
+import { VersioningExtension } from "./Versioning.js";
import {
createInMemoryPreviewController,
createInMemoryVersioningAdapter,
@@ -65,41 +66,85 @@ describe("createInMemoryVersioningEndpoints", () => {
expect(content).not.toBe(blocks);
});
+ it("starts with the given initial versions, newest-first", async () => {
+ const older = [{ type: "paragraph", content: "older" }] as any;
+ const newer = [{ type: "paragraph", content: "newer" }] as any;
+ const endpoints = createInMemoryVersioningEndpoints({
+ initialVersions: [
+ { name: "Older", createdAt: 1000, content: older },
+ { createdAt: 2000, content: newer },
+ ],
+ });
+
+ const { snapshots } = await endpoints.list();
+ expect(
+ snapshots.map((s) => ({ name: s.name, createdAt: s.createdAt })),
+ ).toEqual([
+ { name: undefined, createdAt: 2000 },
+ { name: "Older", createdAt: 1000 },
+ ]);
+ expect(await endpoints.getContent(snapshots[1]!)).toEqual(older);
+ // Stored as a copy: mutating what was passed in doesn't change history.
+ older[0].content = "changed";
+ expect(await endpoints.getContent(snapshots[1]!)).not.toEqual(older);
+ });
+
+ it("sorts versions created later above the initial ones", async () => {
+ const future = Date.now() + 60_000;
+ const endpoints = createInMemoryVersioningEndpoints({
+ initialVersions: [{ name: "Loaded", createdAt: future, content: [] }],
+ });
+
+ const created = await endpoints.create!([], { name: "New" });
+ expect(created.createdAt).toBeGreaterThan(future);
+ const { snapshots } = await endpoints.list();
+ expect(snapshots.map((s) => s.name)).toEqual(["New", "Loaded"]);
+ });
+
it("lists snapshots newest-first", async () => {
vi.useFakeTimers();
try {
const endpoints = createInMemoryVersioningEndpoints();
- const s1 = await endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "v1" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ const s1 = await endpoints.create!(
+ [
+ {
+ id: "1",
+ type: "paragraph" as const,
+ content: "v1" as any,
+ props: {} as any,
+ children: [],
+ },
+ ],
+ {},
+ );
vi.advanceTimersByTime(1000);
- const s2 = await endpoints.create!([
- {
- id: "2",
- type: "paragraph" as const,
- content: "v2" as any,
- props: {} as any,
- children: [],
- },
- ]);
-
- const list = await endpoints.list();
- expect(list[0].id).toBe(s2.id);
- expect(list[1].id).toBe(s1.id);
+ const s2 = await endpoints.create!(
+ [
+ {
+ id: "2",
+ type: "paragraph" as const,
+ content: "v2" as any,
+ props: {} as any,
+ children: [],
+ },
+ ],
+ {},
+ );
+
+ const { snapshots } = await endpoints.list();
+ expect(snapshots[0].id).toBe(s2.id);
+ expect(snapshots[1].id).toBe(s1.id);
} finally {
vi.useRealTimers();
}
});
- it("restore creates a backup and returns snapshot content", async () => {
- const endpoints = createInMemoryVersioningEndpoints();
+ it("restore creates a localized backup and returns snapshot content", async () => {
+ const endpoints = createInMemoryVersioningEndpoints(undefined, {
+ ...en.versioning,
+ before_restore: "Vor Wiederherstellung",
+ });
const original = [
{
@@ -110,7 +155,7 @@ describe("createInMemoryVersioningEndpoints", () => {
children: [],
},
];
- const snap = await endpoints.create!(original);
+ const snap = await endpoints.create!(original, {});
const currentDoc = [
{
@@ -125,10 +170,15 @@ describe("createInMemoryVersioningEndpoints", () => {
expect(restored).toEqual(original);
- // A backup snapshot was created
- const list = await endpoints.list();
- expect(list.length).toBe(2);
- const backup = list.find((s) => s.restoredFromSnapshotId === snap.id);
+ // A backup version was created, and the current row records what the
+ // document was restored from.
+ const { current, snapshots } = await endpoints.list();
+ expect(snapshots.length).toBe(2);
+ expect(current.restoredFrom).toEqual({
+ id: snap.id,
+ createdAt: snap.createdAt,
+ });
+ const backup = snapshots.find((s) => s.name === "Vor Wiederherstellung");
expect(backup).toBeDefined();
// The backup contains the current (pre-restore) doc
@@ -153,33 +203,36 @@ describe("createInMemoryVersioningEndpoints", () => {
await endpoints.rename!(snap, "new");
- const list = await endpoints.list();
- expect(list.find((s) => s.id === snap.id)!.name).toBe("new");
+ const { snapshots } = await endpoints.list();
+ expect(snapshots.find((s) => s.id === snap.id)!.name).toBe("new");
});
it("deletes a snapshot and its content", async () => {
const endpoints = createInMemoryVersioningEndpoints();
- const snap = await endpoints.create!([
- {
- id: "1",
- type: "paragraph" as const,
- content: "v1" as any,
- props: {} as any,
- children: [],
- },
- ]);
+ const snap = await endpoints.create!(
+ [
+ {
+ id: "1",
+ type: "paragraph" as const,
+ content: "v1" as any,
+ props: {} as any,
+ children: [],
+ },
+ ],
+ {},
+ );
await endpoints.remove!(snap);
// No longer listed
- expect(await endpoints.list()).toHaveLength(0);
+ expect((await endpoints.list()).snapshots).toHaveLength(0);
// Its content is gone too
await expect(endpoints.getContent(snap)).rejects.toThrow(/not found/i);
});
it("throws for unknown snapshot ID", async () => {
const endpoints = createInMemoryVersioningEndpoints();
- const missing = { id: "nope", createdAt: 0, updatedAt: 0 };
+ const missing = { id: "nope", createdAt: 0 };
await expect(endpoints.getContent(missing)).rejects.toThrow(/not found/i);
await expect(endpoints.restore!([], missing)).rejects.toThrow(/not found/i);
await expect(endpoints.rename!(missing, "x")).rejects.toThrow(/not found/i);
@@ -296,38 +349,70 @@ describe("VersioningExtension + in-memory adapter", () => {
// 3. Create another snapshot
await ext.create!({ name: "v2" });
- // 4. List — both present (the adapter also surfaces a "current version"
- // entry, which isn't a stored snapshot).
- const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID);
- expect(list).toHaveLength(2);
- expect(list.map((s) => s.name)).toContain("v1");
- expect(list.map((s) => s.name)).toContain("v2");
+ // 4. Reopen history — the backend owns the new list shape.
+ const { current, snapshots } = await ext.list();
+ expect(current.name).toBe("v2");
+ expect(snapshots).toHaveLength(1);
+ expect(snapshots.map((s) => s.name)).toContain("v1");
- // 5. Preview the first snapshot
+ // 5. Preview the first version
await ext.previewSnapshot(snap1.id);
expect(getEditorText(editor)).toBe("initial doc");
- expect(ext.store.state.previewedSnapshotId).toBe(snap1.id);
+ expect(ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: snap1.id,
+ compareToId: undefined,
+ });
// 6. Exit preview — back to modified doc
ext.exitPreview();
expect(getEditorText(editor)).toBe("modified doc");
- expect(ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ext.store.state.view).toEqual({ mode: "live" });
- // 7. Restore the first snapshot
+ // 7. Restore the first version
const restored = await ext.restore!(snap1.id);
expect(restored).toBeDefined();
expect(getEditorText(editor)).toBe("initial doc");
- // 8. A backup snapshot was created by the endpoints (plus the adapter's
- // "current version" entry, which isn't a stored snapshot).
- const afterRestore = (await ext.list()).filter(
- (s) => s.id !== CURRENT_VERSION_ID,
- );
- expect(afterRestore.length).toBe(3);
- const backup = afterRestore.find(
- (s) => s.restoredFromSnapshotId === snap1.id,
- );
- expect(backup).toBeDefined();
+ // 8. A backup version was created by the endpoints, and the current row
+ // records where the restore came from.
+ const afterRestore = await ext.list();
+ expect(afterRestore.snapshots.length).toBe(3);
+ expect(afterRestore.current.restoredFrom).toEqual({
+ id: snap1.id,
+ createdAt: snap1.createdAt,
+ });
+ });
+
+ it("stamps the current row with the last edit time", async () => {
+ const adapter = createInMemoryVersioningAdapter(editor);
+ const ext = VersioningExtension(adapter)({ editor });
+
+ const before = Date.now();
+ setEditorText(editor, "edited doc");
+
+ const { current } = await ext.list();
+ expect(current.createdAt).toBeGreaterThanOrEqual(before);
+ });
+
+ it("stamps a restore before refreshing the current row", async () => {
+ const adapter = createInMemoryVersioningAdapter(editor);
+ const ext = VersioningExtension(adapter)({ editor });
+ const snapshot = await ext.create!();
+ setEditorText(editor, "new content");
+ await ext.previewSnapshot(snapshot.id);
+
+ const restoredAt = Date.now() + 1000;
+ const clock = vi.spyOn(Date, "now").mockReturnValue(restoredAt);
+ try {
+ await ext.restore!(snapshot.id);
+ expect(ext.store.state.list).toMatchObject({
+ loaded: true,
+ current: { createdAt: restoredAt },
+ });
+ } finally {
+ clock.mockRestore();
+ }
});
it("preview with compareTo fetches both contents", async () => {
@@ -337,6 +422,7 @@ describe("VersioningExtension + in-memory adapter", () => {
const snap1 = await ext.create!({ name: "baseline" });
setEditorText(editor, "changed doc");
const snap2 = await ext.create!({ name: "current" });
+ await ext.list();
// Preview snap2 compared to snap1. Without the (opt-in) DiffVersioningExtension
// registered, the in-memory preview controller falls back to a static swap:
@@ -357,16 +443,12 @@ describe("VersioningExtension + in-memory adapter", () => {
const snap2 = await ext.create!({ name: "remove" });
await ext.list();
- expect(ext.canRemove).toBe(true);
+ expect(ext.remove).toBeDefined();
await ext.remove!(snap2.id);
- // Gone from the optimistic store...
- expect(
- ext.store.state.snapshots.find((s) => s.id === snap2.id),
- ).toBeUndefined();
- // ...and gone from the backend's authoritative list.
- const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID);
- expect(list.map((s) => s.id)).toEqual([snap1.id]);
+ // Gone from the backend's authoritative list.
+ const { snapshots } = await ext.list();
+ expect(snapshots.map((s) => s.id)).toEqual([snap1.id]);
});
it("deleting the previewed snapshot exits preview", async () => {
@@ -376,14 +458,18 @@ describe("VersioningExtension + in-memory adapter", () => {
const snap = await ext.create!({ name: "v1" });
setEditorText(editor, "modified doc");
- // Preview the snapshot, then delete the version being previewed.
+ // Preview the version, then delete the one being previewed.
await ext.previewSnapshot(snap.id);
- expect(ext.store.state.previewedSnapshotId).toBe(snap.id);
+ expect(ext.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: snap.id,
+ compareToId: undefined,
+ });
await ext.remove!(snap.id);
// Preview was exited and the live document restored.
- expect(ext.store.state.previewedSnapshotId).toBeUndefined();
+ expect(ext.store.state.view).toEqual({ mode: "live" });
expect(getEditorText(editor)).toBe("modified doc");
});
@@ -394,14 +480,119 @@ describe("VersioningExtension + in-memory adapter", () => {
const snap = await ext.create!({ name: "draft" });
await ext.rename!(snap.id, "final");
- // Store was updated optimistically
- expect(ext.store.state.snapshots.find((s) => s.id === snap.id)!.name).toBe(
- "final",
+ // Store was patched in place
+ const listed = ext.store.state.list;
+ expect(listed.loaded).toBe(true);
+ expect(listed.loaded ? listed.current.name : undefined).toBe("final");
+
+ // A fresh list keeps the named checkpoint in the Current slot until an edit.
+ const { current, snapshots } = await ext.list();
+ expect(current).toMatchObject({ id: snap.id, name: "final" });
+ expect(snapshots).toHaveLength(0);
+ });
+
+ it("keeps a named current checkpoint across refreshes without an edit", async () => {
+ const ext = VersioningExtension(createInMemoryVersioningAdapter(editor))({
+ editor,
+ });
+ await ext.list();
+
+ const named = await ext.create!({ name: "Checkpoint" });
+ const reopened = await ext.list();
+ expect(reopened.current).toMatchObject({
+ id: named.id,
+ name: "Checkpoint",
+ createdAt: named.createdAt,
+ });
+ expect(reopened.snapshots).toHaveLength(0);
+
+ // Naming this same row again is a rename, not another content checkpoint.
+ await ext.rename!(reopened.current.id, "Renamed checkpoint");
+ const renamed = await ext.list();
+ expect(renamed.current).toMatchObject({
+ id: named.id,
+ name: "Renamed checkpoint",
+ });
+ expect(renamed.snapshots).toHaveLength(0);
+
+ const namedAgain = await ext.create!({ name: "One more name" });
+ expect(namedAgain.id).toBe(named.id);
+ expect((await ext.list()).snapshots).toHaveLength(0);
+ });
+
+ it("moves the immutable named checkpoint into history after an edit", async () => {
+ const ext = VersioningExtension(createInMemoryVersioningAdapter(editor))({
+ editor,
+ });
+ await ext.list();
+ const named = await ext.create!({ name: "Before edit" });
+ await ext.list();
+
+ setEditorText(editor, "edited doc");
+ const reopened = await ext.list();
+ expect(reopened.current.id).not.toBe(named.id);
+ expect(reopened.current.name).toBeUndefined();
+ expect(reopened.snapshots).toEqual([
+ expect.objectContaining({ id: named.id, name: "Before edit" }),
+ ]);
+ await ext.previewSnapshot(named.id);
+ expect(getEditorText(editor)).toBe("initial doc");
+ ext.exitPreview();
+ expect(await ext.create!({ name: "After edit" })).not.toMatchObject({
+ id: named.id,
+ });
+ const afterSecondName = await ext.list();
+ expect(afterSecondName.snapshots).toEqual([
+ expect.objectContaining({ id: named.id }),
+ ]);
+ });
+
+ it("keeps a named version durable through reopen, restore, and delete", async () => {
+ const adapter = createInMemoryVersioningAdapter(editor);
+ const ext = VersioningExtension(adapter)({ editor });
+
+ const named = await ext.create!({ name: "Milestone" });
+ expect(ext.store.state.list).toMatchObject({
+ loaded: true,
+ current: { id: named.id, name: "Milestone" },
+ snapshots: [],
+ });
+
+ await ext.rename!(named.id, "Final");
+ const reopened = await ext.list();
+ expect(reopened.current).toMatchObject({ id: named.id, name: "Final" });
+ expect(reopened.snapshots).toHaveLength(0);
+
+ await ext.restore!(named.id);
+ expect(getEditorText(editor)).toBe("initial doc");
+ expect((await ext.list()).current.restoredFrom).toMatchObject({
+ id: named.id,
+ });
+
+ await ext.remove!(named.id);
+ expect((await ext.list()).snapshots.some((s) => s.id === named.id)).toBe(
+ false,
);
+ });
- // Backend also updated (verified via list which calls endpoints.list)
- const list = await ext.list();
- expect(list.find((s) => s.id === snap.id)!.name).toBe("final");
+ it("keeps the undo backup when restoring a named current checkpoint", async () => {
+ const ext = VersioningExtension(createInMemoryVersioningAdapter(editor))({
+ editor,
+ });
+ const named = await ext.create!({ name: "Checkpoint" });
+ await ext.list();
+
+ await ext.restore!(named.id);
+ const listed = await ext.list();
+ expect(listed.current.id).not.toBe(named.id);
+ expect(listed.current.restoredFrom).toEqual({
+ id: named.id,
+ createdAt: named.createdAt,
+ });
+ expect(listed.snapshots.map((s) => s.name)).toEqual([
+ en.versioning.before_restore,
+ "Checkpoint",
+ ]);
});
});
@@ -442,6 +633,7 @@ describe("in-memory versioning + DiffVersioningExtension", () => {
const snap1 = await ext.create!({ name: "baseline" });
setEditorText(editor, "changed doc");
const snap2 = await ext.create!({ name: "current" });
+ await ext.list();
await ext.previewSnapshot(snap2.id, { compareTo: snap1.id });
diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
index 75aae103d0..6d6ac53456 100644
--- a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
+++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts
@@ -1,45 +1,52 @@
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { Block } from "../../blocks/defaultBlocks.js";
+import type { Dictionary } from "../../i18n/dictionary.js";
+import { en } from "../../i18n/locales/en.js";
import type { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js";
import type {
PreviewController,
+ PreviewTarget,
VersioningEndpoints,
VersioningExtensionOptions,
VersionSnapshot,
} from "./Versioning.js";
-import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst } from "./Versioning.js";
-/**
- * Label shown on a diff's marks for the version that introduced the changes.
- * The previewed snapshot is the "new" side of the diff; the current-version
- * entry (previewing the live doc) has no name, so it reads "Current version".
- */
-function versionLabel(snapshot: VersionSnapshot): string {
- if (snapshot.id === CURRENT_VERSION_ID) {
- return "Current version";
+/** Reserved current-row id; stored versions use numeric ids. */
+export const IN_MEMORY_CURRENT_VERSION_ID = "current";
+
+/** Label for the version introducing the diff's changes. */
+function versionLabel(target: PreviewTarget, dictionary: Dictionary): string {
+ switch (target.kind) {
+ case "current":
+ return target.snapshot.name ?? dictionary.versioning.current_version;
+ case "snapshot":
+ return target.snapshot.name ?? dictionary.versioning.unnamed_version;
}
- return snapshot.name ?? "Unnamed version";
}
// ---------------------------------------------------------------------------
// Preview Controller
// ---------------------------------------------------------------------------
+/** Preview controller exposing the live document while a preview replaces it. */
+export type InMemoryPreviewController = PreviewController<
+ Block[]
+> & {
+ applyRestore: (snapshotContent: Block[]) => void;
+ /** Saved live content while previewing, otherwise the editor document. */
+ getLiveDocument: () => Block[];
+ /** Whether a preview has replaced the live document on screen. */
+ readonly isPreviewing: boolean;
+};
+
/**
- * Create a {@link PreviewController} that swaps the BlockNote document in and
- * out using `editor.replaceBlocks`.
- *
- * When entering preview mode the current document is saved so it can be
- * restored on exit. Successive `enterPreview` calls without an intervening
- * `exitPreview` preserve the original saved document.
+ * Swap preview content through `replaceBlocks`, preserving the live document
+ * across successive previews until exit.
*/
export function createInMemoryPreviewController(
editor: BlockNoteEditor,
-): PreviewController[]> {
+): InMemoryPreviewController {
let savedDoc: Block[] | undefined;
- // True while a diff (attribution marks) is on screen, so exit/restore knows to
- // route the cleanup through the diff extension's node-view rebuild.
- let showingDiff = false;
const replaceDoc = (blocks: Block[]) => {
editor.replaceBlocks(editor.document, blocks);
@@ -60,11 +67,17 @@ export function createInMemoryPreviewController(
get supportsComparison() {
return getDiff() !== undefined;
},
+ get isPreviewing() {
+ return savedDoc !== undefined;
+ },
+ getLiveDocument() {
+ return savedDoc ?? editor.document;
+ },
enterPreview(
snapshotContent: Block[],
compareToContent?: Block[],
_attributions?: unknown,
- context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot },
+ context?: { target: PreviewTarget; compareTo?: VersionSnapshot },
) {
// Save the live doc on first enter (successive enters keep the original).
if (savedDoc === undefined) {
@@ -78,41 +91,30 @@ export function createInMemoryPreviewController(
diff.renderDiff(
snapshotContent,
compareToContent,
- context && versionLabel(context.snapshot),
+ context && versionLabel(context.target, editor.dictionary),
);
- showingDiff = true;
return;
}
// No comparison requested, or no diff extension registered: just show the
// snapshot content statically.
- showingDiff = false;
replaceDoc(snapshotContent);
},
exitPreview() {
if (savedDoc !== undefined) {
- const diff = getDiff();
- if (showingDiff && diff) {
- diff.clearDiff(savedDoc);
- } else {
- replaceDoc(savedDoc);
- }
+ // Replacing the blocks also drops the attribution marks a diff leaves.
+ replaceDoc(savedDoc);
savedDoc = undefined;
- showingDiff = false;
}
},
applyRestore(snapshotContent: Block[]) {
- const diff = getDiff();
- if (showingDiff && diff) {
- diff.clearDiff(snapshotContent);
- } else {
- replaceDoc(snapshotContent);
- }
- // Clear saved doc — the restored content is now the live document.
+ // The restored content is the live document from here on, so leave
+ // preview state *before* replacing it: the replace below is an edit, not
+ // a preview transition.
savedDoc = undefined;
- showingDiff = false;
+ replaceDoc(snapshotContent);
},
};
}
@@ -122,23 +124,38 @@ export function createInMemoryPreviewController(
// ---------------------------------------------------------------------------
/**
- * Create a {@link VersioningEndpoints} that stores snapshots entirely in
- * memory. Useful for local-only / non-collaborative editors where you want
- * versioning without any persistence layer.
- *
- * Snapshots are stored as BlockNote document JSON (`Block[]`).
+ * A version to start an in-memory store with
+ * (see {@link InMemoryVersioningOptions.initialVersions}).
*/
-export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
- Block[],
- Block[]
-> {
+export type InMemoryVersion = {
+ /** The version's name. Leave unset for an automatic (unnamed) version. */
+ name?: string;
+ /** When the version was created (unix ms). */
+ createdAt: number;
+ /** The document as of this version. */
+ content: Block[];
+};
+
+export type InMemoryVersioningOptions = {
+ /** Preloaded history. New versions always sort above these, even with future dates. */
+ initialVersions?: InMemoryVersion[];
+};
+
+/** In-memory snapshot storage using BlockNote document JSON (`Block[]`). */
+export function createInMemoryVersioningEndpoints(
+ options: InMemoryVersioningOptions = {},
+ versioningDictionary: Dictionary["versioning"] = en.versioning,
+): VersioningEndpoints[], Block[]> {
const snapshots: VersionSnapshot[] = [];
const contents = new Map[]>();
let nextId = 1;
+ // Set by `restore`, so the current row can show "Restored from " until
+ // the next version is named.
+ let currentRestoredFrom: VersionSnapshot["restoredFrom"];
- // `Date.now()` only has millisecond resolution, so two snapshots created in
- // the same tick would share a timestamp and `sortSnapshotsNewestFirst` (which
- // has nothing else to order on) could list them oldest-first. Hand out
+ // `Date.now()` only has millisecond resolution, so two versions created in
+ // the same tick would share a timestamp and sorting by creation time could
+ // list them oldest-first. Hand out
// strictly increasing timestamps so creation order is always preserved.
let lastTimestamp = 0;
function nextTimestamp() {
@@ -146,9 +163,28 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
return lastTimestamp;
}
+ for (const version of options.initialVersions ?? []) {
+ const id = String(nextId++);
+ snapshots.push({ id, name: version.name, createdAt: version.createdAt });
+ contents.set(id, structuredClone(version.content));
+ // Whatever is created from here on must sort above the loaded history,
+ // even when that history carries timestamps from the future.
+ lastTimestamp = Math.max(lastTimestamp, version.createdAt);
+ }
+
return {
async list() {
- return sortSnapshotsNewestFirst([...snapshots]);
+ // The current row is the live document. It has no stored content (it *is*
+ // the editor's content), so it only carries display metadata; the adapter
+ // overrides `createdAt` with the real last-edit time it tracks.
+ return {
+ current: {
+ id: IN_MEMORY_CURRENT_VERSION_ID,
+ createdAt: nextTimestamp(),
+ restoredFrom: currentRestoredFrom,
+ },
+ snapshots: [...snapshots].sort((a, b) => b.createdAt - a.createdAt),
+ };
},
async create(currentDoc, options) {
@@ -156,46 +192,44 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
const id = String(nextId++);
const snapshot: VersionSnapshot = {
id,
- name: options?.name,
+ name: options.name,
createdAt: now,
- updatedAt: now,
};
snapshots.push(snapshot);
contents.set(id, structuredClone(currentDoc));
+ // The named version now covers everything up to now, so the current row
+ // starts fresh.
+ currentRestoredFrom = undefined;
return snapshot;
},
async restore(currentDoc, snapshot) {
- // Stored snapshots always have string ids (only the synthetic current
- // entry carries the symbol, and it never reaches these methods).
- const id = String(snapshot.id);
+ const id = snapshot.id;
const snapshotContent = contents.get(id);
if (!snapshotContent) {
throw new Error(`Snapshot ${id} not found`);
}
- // Create a "Restored from …" snapshot of the current state before
- // restoring, so the user can undo the restore.
+ // Capture the pre-restore state as its own version so the restore can be
+ // undone — the in-memory backend has no continuous history to fall back
+ // on the way a server-backed one does.
const now = nextTimestamp();
const backupId = String(nextId++);
- const backup: VersionSnapshot = {
+ snapshots.push({
id: backupId,
- name: "Before restore",
+ name: versioningDictionary.before_restore,
createdAt: now,
- updatedAt: now,
- restoredFromSnapshotId: id,
- };
- snapshots.push(backup);
+ });
contents.set(backupId, structuredClone(currentDoc));
+ currentRestoredFrom = { id: snapshot.id, createdAt: snapshot.createdAt };
return structuredClone(snapshotContent);
},
async getContent(snapshot) {
- const id = String(snapshot.id);
- const content = contents.get(id);
+ const content = contents.get(snapshot.id);
if (!content) {
- throw new Error(`Snapshot ${id} not found`);
+ throw new Error(`Snapshot ${snapshot.id} not found`);
}
return structuredClone(content);
},
@@ -203,19 +237,18 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
async rename(snapshot, name) {
const stored = snapshots.find((s) => s.id === snapshot.id);
if (!stored) {
- throw new Error(`Snapshot ${String(snapshot.id)} not found`);
+ throw new Error(`Snapshot ${snapshot.id} not found`);
}
stored.name = name;
- stored.updatedAt = nextTimestamp();
},
async remove(snapshot) {
const index = snapshots.findIndex((s) => s.id === snapshot.id);
if (index === -1) {
- throw new Error(`Snapshot ${String(snapshot.id)} not found`);
+ throw new Error(`Snapshot ${snapshot.id} not found`);
}
snapshots.splice(index, 1);
- contents.delete(String(snapshot.id));
+ contents.delete(snapshot.id);
},
};
}
@@ -235,39 +268,111 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
*
* const editor = BlockNoteEditor.create({
* extensions: [
- * VersioningExtension(createInMemoryVersioningAdapter(editor)),
+ * VersioningExtension(createInMemoryVersioningAdapter),
* ],
* });
+ *
+ * // With history loaded from elsewhere:
+ * VersioningExtension((editor) =>
+ * createInMemoryVersioningAdapter(editor, { initialVersions }),
+ * );
* ```
*/
export function createInMemoryVersioningAdapter(
editor: BlockNoteEditor,
+ options?: InMemoryVersioningOptions,
): VersioningExtensionOptions[], Block[]> {
- const endpoints = createInMemoryVersioningEndpoints();
+ const endpoints = createInMemoryVersioningEndpoints(
+ options,
+ editor.dictionary.versioning,
+ );
+ const preview = createInMemoryPreviewController(editor);
+
+ // With no server there is no authoritative "last edit" timestamp, so the
+ // adapter keeps one off the editor's own change stream. The client clock is
+ // fine here: nothing else reads these timestamps back. Only edits to the
+ // *live* document count: a preview replaces the document too, and swapping
+ // versions on screen is not editing.
+ const loadedAt = Date.now();
+ let lastEditedAt: number | undefined;
+ let editRevision = 0;
+ let currentCheckpoint: { id: string; revision: number } | undefined;
+ editor.onChange(() => {
+ if (!preview.isPreviewing) {
+ lastEditedAt = Date.now();
+ editRevision++;
+ }
+ });
return {
- // The raw endpoints are pure snapshot storage. The "current version" is a
- // view concern owned by the adapter (it's the layer that knows about the
- // live editor), so we wrap `list()` to always surface a current entry: the
- // live document is the editable working copy, and the entry is how the user
- // returns to live editing and compares against saved snapshots. No
- // timestamp/author is tracked, so the row just reads "Current version"
- // (see CurrentSnapshot in @blocknote/react).
+ // The raw endpoints are pure version storage. The current version is the
+ // live document, so the adapter — the layer that knows about the editor —
+ // stamps it with the real last-edit time.
endpoints: {
...endpoints,
- list: async () => {
- const current: VersionSnapshot = {
- id: CURRENT_VERSION_ID,
- createdAt: Date.now(),
- updatedAt: Date.now(),
+ async list() {
+ const { current, snapshots } = await endpoints.list();
+ // A checkpoint still represents the live document until it changes.
+ // Keep its identity in the Current slot across sidebar sessions rather
+ // than showing a second, synthetic row with the same content.
+ const activeCheckpoint = currentCheckpoint;
+ const checkpoint =
+ activeCheckpoint?.revision === editRevision
+ ? snapshots.find((s) => s.id === activeCheckpoint.id)
+ : undefined;
+ return {
+ current: checkpoint ?? {
+ ...current,
+ createdAt: lastEditedAt ?? loadedAt,
+ },
+ snapshots: checkpoint
+ ? snapshots.filter((s) => s.id !== checkpoint.id)
+ : snapshots,
};
- return [current, ...(await endpoints.list())];
},
+ async create(currentDoc, options) {
+ // An explicit second name before an edit renames the same checkpoint.
+ const activeCheckpoint = currentCheckpoint;
+ if (
+ options.name !== undefined &&
+ activeCheckpoint?.revision === editRevision
+ ) {
+ const { snapshots } = await endpoints.list();
+ const checkpoint = snapshots.find(
+ (s) => s.id === activeCheckpoint.id,
+ );
+ if (checkpoint) {
+ await endpoints.rename!(checkpoint, options.name);
+ return checkpoint;
+ }
+ }
+ const snapshot = await endpoints.create!(currentDoc, options);
+ currentCheckpoint = { id: snapshot.id, revision: editRevision };
+ return snapshot;
+ },
+ async restore(currentDoc, snapshot) {
+ const restored = await endpoints.restore!(currentDoc, snapshot);
+ currentCheckpoint = undefined;
+ return restored;
+ },
+ async remove(snapshot) {
+ await endpoints.remove!(snapshot);
+ if (currentCheckpoint?.id === snapshot.id) {
+ currentCheckpoint = undefined;
+ }
+ },
+ },
+ preview,
+ // Both read the *live* document through the controller: while a preview is
+ // open, `editor.document` holds the previewed version, and naming or
+ // showing the current version must not capture that.
+ getCurrentDocument() {
+ return preview.getLiveDocument();
+ },
+ // The live document is already in the version content format (`Block[]`),
+ // so previewing the current version just reuses the live blocks.
+ serializeCurrentContent() {
+ return preview.getLiveDocument();
},
- preview: createInMemoryPreviewController(editor),
- getCurrentDocument: () => editor.document,
- // The live document is already in the snapshot content format (`Block[]`),
- // so previewing "current" as a diff just reuses the live blocks.
- serializeCurrentContent: () => editor.document,
};
}
diff --git a/packages/core/src/extensions/Versioning/index.ts b/packages/core/src/extensions/Versioning/index.ts
index c24920adc1..980281d4eb 100644
--- a/packages/core/src/extensions/Versioning/index.ts
+++ b/packages/core/src/extensions/Versioning/index.ts
@@ -1,2 +1,3 @@
export * from "./Versioning.js";
export * from "./inMemoryVersioning.js";
+export * from "./scrollToFirstChange.js";
diff --git a/packages/core/src/extensions/Versioning/list.test.ts b/packages/core/src/extensions/Versioning/list.test.ts
new file mode 100644
index 0000000000..7215d37775
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/list.test.ts
@@ -0,0 +1,183 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { Store } from "../../util/Store.js";
+import { createListSession } from "./list.js";
+import type {
+ VersioningEndpoints,
+ VersioningState,
+ VersionSnapshot,
+} from "./types.js";
+
+function snap(id: string, createdAt: number): VersionSnapshot {
+ return { id, createdAt };
+}
+
+/** Resolve or reject a request at an explicit point in a loading transition. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+type ListResult = Awaited>;
+
+function setup(initialState?: VersioningState) {
+ const store = new Store(
+ initialState ?? {
+ list: { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ },
+ );
+ const list = vi.fn();
+ const endpoints: VersioningEndpoints = {
+ list,
+ // `createListSession` only calls `list`; the other endpoints are stubs to
+ // satisfy the interface.
+ getContent: async () => undefined,
+ };
+ const session = createListSession({ store, endpoints });
+ return { store, list, endpoints, session };
+}
+
+describe("createListSession", () => {
+ it("starts idle and unloaded", () => {
+ const { store } = setup();
+ expect(store.state.listing).toBe(false);
+ expect(store.state.list).toEqual({ loaded: false });
+ expect(store.state.view).toEqual({ mode: "live" });
+ });
+
+ it("fetches, stores the list sorted newest-first, and returns it", async () => {
+ const snapshots = [snap("a", 100), snap("b", 300), snap("c", 200)];
+ const current = snap("current", 400);
+ const { list, store, session } = setup();
+ list.mockResolvedValue({ current, snapshots });
+
+ const result = await session.refresh();
+
+ expect(list).toHaveBeenCalledTimes(1);
+ // The returned list is sorted newest-first...
+ expect(result.snapshots.map((s) => s.id)).toEqual(["b", "c", "a"]);
+ // ...without mutating the backend array...
+ expect(snapshots.map((s) => s.id)).toEqual(["a", "b", "c"]);
+ // ...and is what landed in the store.
+ expect(store.state.list).toBe(result);
+ expect(store.state.list).toEqual({
+ loaded: true,
+ current,
+ snapshots: [snap("b", 300), snap("c", 200), snap("a", 100)],
+ });
+ });
+
+ it("publishes listing on the idle→busy and busy→idle transitions", async () => {
+ const request = deferred();
+ const { list, store, session } = setup();
+ list.mockReturnValue(request.promise);
+ let listingTransitions = 0;
+ store.subscribe(({ prevVal, currentVal }) => {
+ if (prevVal.listing !== currentVal.listing) {
+ listingTransitions++;
+ }
+ });
+
+ const pending = session.refresh();
+ expect(store.state.listing).toBe(true);
+ expect(listingTransitions).toBe(1);
+
+ request.resolve({ current: snap("current", 10), snapshots: [] });
+ await pending;
+ expect(store.state.listing).toBe(false);
+ expect(listingTransitions).toBe(2);
+ });
+
+ it("is listing while pending and idle once settled", async () => {
+ const request = deferred();
+ const { list, store, session } = setup();
+ list.mockReturnValue(request.promise);
+
+ const pending = session.refresh();
+ expect(store.state.listing).toBe(true);
+
+ request.resolve({ current: snap("current", 10), snapshots: [] });
+ await pending;
+ expect(store.state.listing).toBe(false);
+ });
+
+ it("joins an in-flight fetch instead of re-listing", async () => {
+ const request = deferred();
+ const { list, store, session } = setup();
+ list.mockReturnValue(request.promise);
+ let listingTransitions = 0;
+ store.subscribe(({ prevVal, currentVal }) => {
+ if (prevVal.listing !== currentVal.listing) {
+ listingTransitions++;
+ }
+ });
+
+ const first = session.refresh();
+ const second = session.refresh();
+ expect(second).toBe(first);
+ expect(list).toHaveBeenCalledTimes(1);
+ // Only one busy transition despite two callers.
+ expect(listingTransitions).toBe(1);
+
+ request.resolve({ current: snap("current", 10), snapshots: [] });
+ await Promise.all([first, second]);
+ // Busy→idle fires once too.
+ expect(listingTransitions).toBe(2);
+
+ // Once settled, a new refresh fetches again.
+ const third = session.refresh();
+ expect(list).toHaveBeenCalledTimes(2);
+ expect(third).not.toBe(first);
+ await third;
+ });
+
+ it("keeps the previous list and reports idle when a fetch fails, then retries", async () => {
+ const previousList = {
+ loaded: true as const,
+ current: snap("current", 30),
+ snapshots: [snap("a", 10)],
+ };
+ const { store, list, session } = setup({
+ list: previousList,
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ });
+
+ const request = deferred();
+ list.mockReturnValue(request.promise);
+
+ const pending = session.refresh();
+ expect(store.state.listing).toBe(true);
+
+ const failure = expect(pending).rejects.toThrow("offline");
+ request.reject(new Error("offline"));
+ await failure;
+
+ // The store keeps its previous list; the fetch is no longer in flight.
+ expect(store.state.list).toBe(previousList);
+ expect(store.state.listing).toBe(false);
+
+ // A later refresh retries and succeeds.
+ list.mockResolvedValue({
+ current: snap("current", 40),
+ snapshots: [snap("b", 20)],
+ });
+ const retried = await session.refresh();
+ expect(list).toHaveBeenCalledTimes(2);
+ expect(retried.snapshots.map((s) => s.id)).toEqual(["b"]);
+ expect(store.state.list).toEqual(retried);
+ expect(store.state.listing).toBe(false);
+ });
+});
diff --git a/packages/core/src/extensions/Versioning/list.ts b/packages/core/src/extensions/Versioning/list.ts
new file mode 100644
index 0000000000..bc9c08df86
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/list.ts
@@ -0,0 +1,66 @@
+import type { Store } from "../../util/Store.js";
+import type {
+ LoadedVersioningList,
+ VersioningEndpoints,
+ VersioningState,
+} from "./types.js";
+
+/**
+ * The list half of the versioning store. Owns the `list` field and publishes
+ * whether a fetch is in flight (`listing`) so the busy status can be derived
+ * on read.
+ */
+export function createListSession({
+ store,
+ endpoints,
+}: {
+ store: Store;
+ endpoints: VersioningEndpoints;
+}) {
+ // At most one fetch is ever in flight: a call made while one is pending joins
+ // it rather than hitting the backend again. Listing is a short-lived GET and
+ // while it is pending the UI shows a loading state, so no mutation can land
+ // in that window. With no second concurrent fetch there is no out-of-order
+ // write and no need for a counter — this object is both the join key and the
+ // busy flag.
+ let latestRequest: {
+ pending: boolean;
+ promise: Promise;
+ } | null = null;
+
+ /**
+ * Fetch the list, joining an in-flight fetch rather than duplicating it.
+ * Listing never touches `view`: the preview owns that.
+ */
+ function refresh(): Promise {
+ if (latestRequest?.pending) {
+ return latestRequest.promise;
+ }
+
+ const promise = endpoints.list().then(({ current, snapshots }) => {
+ const result: LoadedVersioningList = {
+ loaded: true as const,
+ current,
+ snapshots: [...snapshots].sort((a, b) => b.createdAt - a.createdAt),
+ };
+ store.setState((state) => ({ ...state, list: result }));
+ return result;
+ });
+
+ const entry = { pending: true, promise };
+ latestRequest = entry;
+ store.setState((state) => ({ ...state, listing: true }));
+
+ const settle = () => {
+ // Nothing can supersede `entry` while it is pending: `refresh` joins the
+ // in-flight fetch instead of starting another, so this always applies.
+ entry.pending = false;
+ store.setState((state) => ({ ...state, listing: false }));
+ };
+ promise.then(settle, settle);
+
+ return promise;
+ }
+
+ return { refresh };
+}
diff --git a/packages/core/src/extensions/Versioning/preview.test.ts b/packages/core/src/extensions/Versioning/preview.test.ts
new file mode 100644
index 0000000000..48d764c1be
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/preview.test.ts
@@ -0,0 +1,452 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { Store } from "../../util/Store.js";
+import {
+ createPreviewSession,
+ LOADING_PREVIEW_CLASS,
+ LOADING_PREVIEW_DELAY_MS,
+} from "./preview.js";
+import type {
+ PreviewController,
+ VersioningEndpoints,
+ VersioningList,
+ VersioningState,
+ VersionSnapshot,
+} from "./types.js";
+
+function snap(
+ id: string,
+ createdAt: number,
+ extra?: Partial,
+): VersionSnapshot {
+ return { id, createdAt, ...extra };
+}
+
+function loadedList(
+ snapshots: VersionSnapshot[],
+ current: VersionSnapshot = snap("current", 30),
+): VersioningList {
+ return { loaded: true, current, snapshots };
+}
+
+/** Resolve or reject a request at an explicit point in a loading transition. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function makeSession(opts?: {
+ list?: VersioningList;
+ serializeCurrentContent?: () => any;
+}) {
+ const store = new Store({
+ list: opts?.list ?? { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ });
+ const preview = {
+ enterPreview: vi.fn(),
+ exitPreview: vi.fn(),
+ applyRestore: vi.fn>(),
+ } satisfies PreviewController;
+ const classList = { add: vi.fn(), remove: vi.fn() };
+ const editor = {
+ domElement: { classList },
+ } as unknown as BlockNoteEditor;
+ const getContent = vi.fn();
+ const getAttributions =
+ vi.fn>();
+ const endpoints: VersioningEndpoints = {
+ list: async () => ({ current: snap("current", 30), snapshots: [] }),
+ getContent,
+ getAttributions,
+ };
+ const session = createPreviewSession({
+ store,
+ endpoints,
+ preview,
+ serializeCurrentContent: opts?.serializeCurrentContent,
+ editor,
+ scrollToFirstChangeEnabled: false,
+ });
+ return {
+ store,
+ preview,
+ classList,
+ editor,
+ getContent,
+ getAttributions,
+ session,
+ };
+}
+
+describe("createPreviewSession", () => {
+ it("previews a snapshot: renders it, tracks the view, and reports loading", async () => {
+ const stored = snap("a", 10);
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([stored]),
+ });
+ getContent.mockResolvedValue("content a");
+
+ const pending = session.previewSnapshot("a");
+
+ // Synchronous before the fetch settles: view is set, loading reported.
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "a",
+ compareToId: undefined,
+ });
+ expect(store.state.loadingView).toEqual({
+ mode: "snapshot",
+ snapshotId: "a",
+ compareToId: undefined,
+ });
+
+ await pending;
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+ expect(preview.enterPreview).toHaveBeenCalledWith(
+ "content a",
+ undefined,
+ undefined,
+ { target: { kind: "snapshot", snapshot: stored }, compareTo: undefined },
+ );
+ expect(store.state.loadingView).toBeUndefined();
+ });
+
+ it("rejects when the snapshot id is unknown", async () => {
+ const { session } = makeSession({ list: loadedList([snap("a", 10)]) });
+ await expect(session.previewSnapshot("nope")).rejects.toThrow(
+ "Snapshot not found: nope",
+ );
+ });
+
+ it("fetches the baseline and attributions when comparing against an older version", async () => {
+ const baseline = snap("baseline", 5);
+ const shown = snap("shown", 10);
+ const { store, preview, getContent, getAttributions, session } =
+ makeSession({
+ list: loadedList([shown, baseline], snap("current", 30)),
+ });
+ getContent.mockImplementation(async (snapshot) =>
+ snapshot.id === "shown" ? "shown content" : "baseline content",
+ );
+ getAttributions.mockResolvedValue(["attr"]);
+
+ await session.previewSnapshot("shown", { compareTo: "baseline" });
+
+ expect(getContent).toHaveBeenCalledTimes(2);
+ expect(preview.enterPreview).toHaveBeenCalledWith(
+ "shown content",
+ "baseline content",
+ ["attr"],
+ { target: { kind: "snapshot", snapshot: shown }, compareTo: baseline },
+ );
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "shown",
+ compareToId: "baseline",
+ });
+ });
+
+ it("a superseded preview never renders", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("b", 20), snap("a", 10)]),
+ });
+ const aRequest = deferred();
+ const bRequest = deferred();
+ getContent.mockImplementation(async (snapshot) =>
+ snapshot.id === "a" ? aRequest.promise : bRequest.promise,
+ );
+
+ const first = session.previewSnapshot("a");
+ const second = session.previewSnapshot("b");
+
+ // Resolve the *newer* request first: it renders.
+ bRequest.resolve("content b");
+ await second;
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+ expect(preview.enterPreview).toHaveBeenCalledWith(
+ "content b",
+ undefined,
+ undefined,
+ expect.anything(),
+ );
+
+ // The older request settling late must not draw over the newer preview.
+ aRequest.resolve("content a");
+ await first;
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "b",
+ compareToId: undefined,
+ });
+ });
+
+ it("rolls the view back to live and clears loading when the latest fetch throws", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("a", 10)]),
+ });
+ getContent.mockRejectedValue(new Error("boom"));
+
+ await expect(session.previewSnapshot("a")).rejects.toThrow("boom");
+
+ expect(store.state.view).toEqual({ mode: "live" });
+ expect(store.state.loadingView).toBeUndefined();
+ expect(preview.enterPreview).not.toHaveBeenCalled();
+ });
+
+ it("rolls back to what was actually rendered when a switch fails", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("shown", 20), snap("next", 10)]),
+ });
+ getContent.mockImplementation(async (snapshot) => {
+ if (snapshot.id === "shown") {
+ return "shown content";
+ }
+ throw new Error("offline");
+ });
+
+ await session.previewSnapshot("shown");
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+
+ await expect(session.previewSnapshot("next")).rejects.toThrow("offline");
+
+ // The failed switch never rendered, so the view falls back to the shown one.
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "shown",
+ compareToId: undefined,
+ });
+ expect(store.state.loadingView).toBeUndefined();
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+ });
+
+ it("exits a rendered preview through the controller and restores the live view", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("a", 10)]),
+ });
+ getContent.mockResolvedValue("content a");
+ await session.previewSnapshot("a");
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+
+ session.exitPreview();
+
+ expect(store.state.view).toEqual({ mode: "live" });
+ expect(preview.exitPreview).toHaveBeenCalledTimes(1);
+ expect(store.state.loadingView).toBeUndefined();
+ });
+
+ it("leaves the controller alone while the preview is still fetching", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("a", 10)]),
+ });
+ const request = deferred();
+ getContent.mockReturnValue(request.promise);
+
+ const pending = session.previewSnapshot("a");
+ session.exitPreview();
+
+ // Nothing replaced the document yet, so the controller has nothing to
+ // put back.
+ expect(preview.exitPreview).not.toHaveBeenCalled();
+ expect(store.state.view).toEqual({ mode: "live" });
+ expect(store.state.loadingView).toBeUndefined();
+
+ // Exiting bumped the token: the in-flight fetch bails without rendering.
+ request.resolve("content a");
+ await pending;
+ expect(preview.enterPreview).not.toHaveBeenCalled();
+ expect(store.state.view).toEqual({ mode: "live" });
+ });
+
+ it("leaves the controller alone when already live", async () => {
+ const { store, preview, session } = makeSession();
+ session.exitPreview();
+ session.exitPreview();
+ expect(preview.exitPreview).not.toHaveBeenCalled();
+ expect(store.state.view).toEqual({ mode: "live" });
+ });
+
+ it("exits through a controller that threw while rendering", async () => {
+ const { store, preview, getContent, session } = makeSession({
+ list: loadedList([snap("a", 10)]),
+ });
+ getContent.mockResolvedValue("content a");
+ preview.enterPreview.mockImplementation(() => {
+ throw new Error("render failed");
+ });
+
+ await expect(session.previewSnapshot("a")).rejects.toThrow("render failed");
+
+ // The controller was asked to render, so the view stays on the snapshot
+ // until it has been asked to leave.
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "a",
+ compareToId: undefined,
+ });
+ expect(store.state.loadingView).toBeUndefined();
+
+ session.exitPreview();
+ expect(preview.exitPreview).toHaveBeenCalledTimes(1);
+ expect(store.state.view).toEqual({ mode: "live" });
+ });
+
+ it("marks the editor as loading only once a preview has taken a while", async () => {
+ const { classList, getContent, session } = makeSession({
+ list: loadedList([snap("a", 10)]),
+ });
+ const request = deferred();
+ getContent.mockReturnValue(request.promise);
+
+ vi.useFakeTimers();
+ try {
+ const pending = session.previewSnapshot("a");
+ // Not yet: a fast load must not flash a loading state.
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS - 1);
+ expect(classList.add).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(classList.add).toHaveBeenCalledTimes(1);
+ expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ request.resolve("content a");
+ await pending;
+ expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ // A load that ends before the delay never marks the editor.
+ await session.previewSnapshot("a");
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ expect(classList.add).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not restart the loading timer on fast preview switches", async () => {
+ const { classList, getContent, session } = makeSession({
+ list: loadedList([snap("b", 20), snap("a", 10)]),
+ });
+ const aRequest = deferred();
+ const bRequest = deferred();
+ getContent.mockImplementation(async (snapshot) =>
+ snapshot.id === "a" ? aRequest.promise : bRequest.promise,
+ );
+
+ vi.useFakeTimers();
+ try {
+ const first = session.previewSnapshot("a");
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS - 1);
+ const second = session.previewSnapshot("b");
+ vi.advanceTimersByTime(1);
+
+ // One delay across both previews: the class appears once, not per switch.
+ expect(classList.add).toHaveBeenCalledTimes(1);
+ expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ bRequest.resolve("content b");
+ await second;
+ expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ aRequest.resolve("content a");
+ await first;
+ expect(classList.add).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("clears the delayed loader after a failed switch and can retry", async () => {
+ const { store, preview, classList, getContent, session } = makeSession({
+ list: loadedList([snap("shown", 20), snap("next", 10)]),
+ });
+ getContent.mockImplementation(async (snapshot) =>
+ snapshot.id === "shown" ? "shown content" : "next content",
+ );
+ await session.previewSnapshot("shown");
+ expect(preview.enterPreview).toHaveBeenCalledTimes(1);
+
+ const firstAttempt = deferred();
+ getContent.mockReturnValueOnce(firstAttempt.promise);
+
+ vi.useFakeTimers();
+ try {
+ const pending = session.previewSnapshot("next");
+ vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS);
+ expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ const failure = expect(pending).rejects.toThrow("offline");
+ firstAttempt.reject(new Error("offline"));
+ await failure;
+
+ // Rolled back to what was rendered; the loader is cleared.
+ expect(store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: "shown",
+ compareToId: undefined,
+ });
+ expect(store.state.loadingView).toBeUndefined();
+ expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS);
+
+ // A retry succeeds.
+ await session.previewSnapshot("next");
+ expect(preview.enterPreview).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("exposes previewCurrentVersion only when serializeCurrentContent is provided", () => {
+ const without = makeSession();
+ expect(without.session.previewCurrentVersion).toBeUndefined();
+
+ const withSerialize = makeSession({
+ serializeCurrentContent: () => "live",
+ });
+ expect(withSerialize.session.previewCurrentVersion).toBeDefined();
+ });
+
+ it("previewCurrentVersion passes the current row as the target", async () => {
+ const current = snap("current", 30, { by: ["u1"] });
+ const stored = snap("a", 10);
+ const serialize = vi.fn(() => "live content");
+ const { store, preview, getContent, getAttributions, session } =
+ makeSession({
+ list: loadedList([stored], current),
+ serializeCurrentContent: serialize,
+ });
+ getContent.mockResolvedValue("baseline content");
+ getAttributions.mockResolvedValue(undefined);
+
+ await session.previewCurrentVersion!({ compareTo: "a" });
+
+ expect(serialize).toHaveBeenCalledTimes(1);
+ expect(preview.enterPreview).toHaveBeenCalledWith(
+ "live content",
+ "baseline content",
+ undefined,
+ { target: { kind: "current", snapshot: current }, compareTo: stored },
+ );
+ expect(store.state.view).toEqual({ mode: "current", compareToId: "a" });
+ });
+
+ it("previewCurrentVersion requires the list to be loaded", async () => {
+ const { session } = makeSession({
+ list: { loaded: false },
+ serializeCurrentContent: () => "live",
+ });
+ await expect(session.previewCurrentVersion!()).rejects.toThrow(
+ "requires the version list to be loaded",
+ );
+ });
+});
diff --git a/packages/core/src/extensions/Versioning/preview.ts b/packages/core/src/extensions/Versioning/preview.ts
new file mode 100644
index 0000000000..5406b40127
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/preview.ts
@@ -0,0 +1,210 @@
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { Store } from "../../util/Store.js";
+import {
+ scheduleScrollToFirstChange,
+ scrollToFirstChange,
+} from "./scrollToFirstChange.js";
+import { findSnapshot, resolveCompareTo } from "./state.js";
+import type {
+ PreviewController,
+ PreviewTarget,
+ VersioningEndpoints,
+ VersioningPreviewView,
+ VersionSnapshotIdentifier,
+ VersioningState,
+ VersioningView,
+ VersionSnapshot,
+} from "./types.js";
+
+/** Editor loading class, applied after {@link LOADING_PREVIEW_DELAY_MS}. */
+export const LOADING_PREVIEW_CLASS = "bn-loading";
+
+/** Delay the loading indicator so fast previews do not flash. */
+export const LOADING_PREVIEW_DELAY_MS = 400;
+
+/**
+ * The preview loading indicator. Owns the loading class on the editor, shown
+ * after {@link LOADING_PREVIEW_DELAY_MS} while a preview is fetching and
+ * hidden when it settles. Nothing else — the caller publishes the loading
+ * view to the store.
+ */
+function createLoadingIndicator(editor: BlockNoteEditor) {
+ let loaderTimeout: ReturnType | undefined;
+
+ return {
+ /**
+ * Show or hide the loading class. Showing keeps one delay across fast
+ * preview switches: restarting the timer on every row would re-flash the
+ * class for a preview that is already loading.
+ */
+ setLoading(loading: boolean) {
+ if (loading) {
+ if (loaderTimeout === undefined) {
+ loaderTimeout = setTimeout(() => {
+ editor.domElement?.classList.add(LOADING_PREVIEW_CLASS);
+ }, LOADING_PREVIEW_DELAY_MS);
+ }
+ } else {
+ if (loaderTimeout !== undefined) {
+ clearTimeout(loaderTimeout);
+ loaderTimeout = undefined;
+ }
+ editor.domElement?.classList.remove(LOADING_PREVIEW_CLASS);
+ }
+ },
+ };
+}
+
+/**
+ * The preview half of the versioning store. Owns the `view` and `loadingView`
+ * fields, the loading indicator, and which preview is currently on screen.
+ *
+ * The controller renders synchronously, so the only async step is fetching
+ * content. A single token (bumped for every request and on exit) guarantees
+ * only the newest request renders: an older fetch that settles late — or one
+ * that settles after exit — bails instead of drawing over the document.
+ */
+export function createPreviewSession({
+ store,
+ endpoints,
+ preview,
+ serializeCurrentContent,
+ editor,
+ scrollToFirstChangeEnabled,
+}: {
+ store: Store;
+ endpoints: VersioningEndpoints;
+ preview: PreviewController;
+ serializeCurrentContent?: () => any;
+ editor: BlockNoteEditor;
+ scrollToFirstChangeEnabled: boolean;
+}) {
+ // Newest request wins: only the call holding this token may render.
+ let latestPreview = 0;
+ // The view whose content is actually on screen. Trails the requested view
+ // while content loads, so a failed fetch can put back what was there before
+ // (see the catch below). Set before `enterPreview` so a controller that
+ // throws mid-render still owns the screen until `exitPreview`.
+ let renderedView: VersioningView = { mode: "live" };
+ const loadingIndicator = createLoadingIndicator(editor);
+
+ function setLoading(view: VersioningPreviewView | undefined) {
+ loadingIndicator.setLoading(view !== undefined);
+ if (store.state.loadingView !== view) {
+ store.setState((state) => ({ ...state, loadingView: view }));
+ }
+ }
+
+ async function showPreview(
+ view: VersioningPreviewView,
+ target: PreviewTarget,
+ compareTo: VersionSnapshot | undefined,
+ getPrimaryContent: () => Promise,
+ ) {
+ const request = ++latestPreview;
+ store.setState((state) => ({ ...state, view }));
+ setLoading(view);
+ try {
+ const [content, compareToContent, attributions] = await Promise.all([
+ getPrimaryContent(),
+ compareTo && endpoints.getContent(compareTo),
+ compareTo && endpoints.getAttributions?.(target, compareTo),
+ ]);
+ // A restore is replacing the document: don't draw a preview over it.
+ if (request !== latestPreview || store.state.restoring) {
+ return;
+ }
+ renderedView = view;
+ preview.enterPreview(content, compareToContent, attributions, {
+ target,
+ compareTo,
+ });
+ setLoading(undefined);
+
+ scheduleScrollToFirstChange(() => editor.domElement, {
+ enabled: scrollToFirstChangeEnabled,
+ isCurrent: () => store.state.view === view,
+ });
+ } catch (error) {
+ if (request === latestPreview) {
+ // Back to what is actually on screen; a superseded request owns nothing.
+ store.setState((state) => ({ ...state, view: renderedView }));
+ setLoading(undefined);
+ }
+ throw error;
+ }
+ }
+
+ return {
+ async previewSnapshot(
+ this: void,
+ id: VersionSnapshotIdentifier,
+ previewOptions?: { compareTo?: VersionSnapshotIdentifier },
+ ) {
+ const snapshot = findSnapshot(store.state.list, id);
+ if (snapshot === undefined) {
+ throw new Error(
+ `Snapshot not found: ${typeof id === "object" ? id.id : id}`,
+ );
+ }
+ const compareTo = resolveCompareTo(
+ store.state.list,
+ previewOptions?.compareTo,
+ );
+ await showPreview(
+ {
+ mode: "snapshot",
+ snapshotId: snapshot.id,
+ compareToId: compareTo?.id,
+ },
+ { kind: "snapshot", snapshot },
+ compareTo,
+ () => endpoints.getContent(snapshot),
+ );
+ },
+ ...(serializeCurrentContent
+ ? {
+ async previewCurrentVersion(
+ this: void,
+ previewOptions?: {
+ compareTo?: VersionSnapshotIdentifier;
+ },
+ ) {
+ const versions = store.state.list;
+ if (!versions.loaded) {
+ throw new Error(
+ "previewCurrentVersion requires the version list to be loaded; " +
+ "call `list()` first.",
+ );
+ }
+ const compareTo = resolveCompareTo(
+ store.state.list,
+ previewOptions?.compareTo,
+ );
+ await showPreview(
+ { mode: "current", compareToId: compareTo?.id },
+ { kind: "current", snapshot: versions.current },
+ compareTo,
+ serializeCurrentContent!,
+ );
+ },
+ }
+ : {}),
+ exitPreview(this: void) {
+ // In-flight fetches bail out instead of rendering over the live document.
+ latestPreview++;
+ setLoading(undefined);
+ if (store.state.view.mode === "live") {
+ return;
+ }
+ store.setState((state) => ({ ...state, view: { mode: "live" } }));
+ // Only leave what was rendered; a still-fetching preview never touched
+ // the document.
+ if (renderedView.mode !== "live") {
+ renderedView = { mode: "live" };
+ preview.exitPreview();
+ }
+ },
+ scrollToFirstChange: () => scrollToFirstChange(editor.domElement),
+ };
+}
diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts
new file mode 100644
index 0000000000..2a0a650a3a
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts
@@ -0,0 +1,320 @@
+/**
+ * @vitest-environment jsdom
+ */
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vite-plus/test";
+
+import { scrollToFirstChange } from "./scrollToFirstChange.js";
+
+// jsdom implements neither `scrollIntoView` nor layout, so both are installed
+// here: `scrollIntoView` to observe the call, `getBoundingClientRect` per
+// element to model which nodes have a box.
+const originalAnimate = Object.getOwnPropertyDescriptor(
+ Element.prototype,
+ "animate",
+);
+const animate = vi.fn(
+ (
+ _frames: Keyframe[] | PropertyIndexedKeyframes | null,
+ _options?: number | KeyframeAnimationOptions,
+ ): { cancel: ReturnType; onfinish?: () => void } => ({
+ cancel: vi.fn(),
+ }),
+);
+const hadScrollIntoView = "scrollIntoView" in Element.prototype;
+let scrollIntoView: ReturnType>;
+
+/** Give `element` a non-empty layout box. */
+function withBox(element: Element): Element {
+ element.getBoundingClientRect = () => ({ width: 100, height: 20 }) as DOMRect;
+ return element;
+}
+
+/** Give `element` a zero-sized box, as `display: contents` wrappers have. */
+function withoutBox(element: Element): Element {
+ element.getBoundingClientRect = () => ({ width: 0, height: 0 }) as DOMRect;
+ return element;
+}
+
+function makeRoot(): HTMLElement {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ return root;
+}
+
+beforeEach(() => {
+ animate.mockClear();
+ Object.defineProperty(Element.prototype, "animate", {
+ configurable: true,
+ value: animate,
+ });
+ scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+});
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ if (originalAnimate) {
+ Object.defineProperty(Element.prototype, "animate", originalAnimate);
+ } else {
+ Reflect.deleteProperty(Element.prototype, "animate");
+ }
+ if (!hadScrollIntoView) {
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
+ }
+});
+
+describe("scrollToFirstChange", () => {
+ it("returns false when there is no root", () => {
+ expect(scrollToFirstChange(undefined)).toBe(false);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ });
+
+ it("returns false when the document has no attribution marks", () => {
+ expect(scrollToFirstChange(makeRoot())).toBe(false);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ });
+
+ it("scrolls to the content element of the first mark", () => {
+ const root = makeRoot();
+ const wrapper = document.createElement("span");
+ wrapper.dataset["userIds"] = '["u1"]';
+ const content = withBox(document.createElement("span"));
+ wrapper.appendChild(content);
+ root.appendChild(wrapper);
+
+ expect(scrollToFirstChange(root)).toBe(true);
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(scrollIntoView.mock.instances[0]).toBe(content);
+ });
+
+ it("descends one level further when the content element has no box", () => {
+ const root = makeRoot();
+ const wrapper = document.createElement("div");
+ wrapper.dataset["userIds"] = '["u1"]';
+ const content = withoutBox(document.createElement("div"));
+ const inner = withBox(document.createElement("p"));
+ content.appendChild(inner);
+ wrapper.appendChild(content);
+ root.appendChild(wrapper);
+
+ expect(scrollToFirstChange(root)).toBe(true);
+ expect(scrollIntoView.mock.instances[0]).toBe(inner);
+ });
+
+ it("picks the first mark in document order", () => {
+ const root = makeRoot();
+ for (const id of ["u1", "u2"]) {
+ const wrapper = document.createElement("span");
+ wrapper.dataset["userIds"] = `["${id}"]`;
+ wrapper.appendChild(withBox(document.createElement("span")));
+ root.appendChild(wrapper);
+ }
+
+ scrollToFirstChange(root);
+
+ expect(scrollIntoView.mock.instances[0]).toBe(
+ root.firstElementChild!.firstElementChild,
+ );
+ });
+
+ it("scrolls smoothly by default and instantly under reduced motion", () => {
+ const root = makeRoot();
+ const wrapper = document.createElement("span");
+ wrapper.dataset["userIds"] = '["u1"]';
+ wrapper.appendChild(withBox(document.createElement("span")));
+ root.appendChild(wrapper);
+
+ // jsdom has no `matchMedia`; the helper optional-calls it, so the
+ // no-preference default is exercised by simply leaving it out.
+ scrollToFirstChange(root);
+ expect(scrollIntoView).toHaveBeenLastCalledWith({
+ block: "center",
+ behavior: "smooth",
+ });
+
+ window.matchMedia = vi.fn(() => ({ matches: true }) as MediaQueryList);
+ scrollToFirstChange(root);
+ expect(scrollIntoView).toHaveBeenLastCalledWith({
+ block: "center",
+ behavior: "auto",
+ });
+
+ const frames = animate.mock.calls.at(-1)?.[0];
+ expect(frames).toEqual([
+ expect.not.objectContaining({ transform: expect.anything() }),
+ expect.not.objectContaining({ transform: expect.anything() }),
+ ]);
+
+ Reflect.deleteProperty(window, "matchMedia");
+ });
+
+ it("highlights without DOM mutations and releases the finished animation", () => {
+ const root = makeRoot();
+ const block = document.createElement("div");
+ block.className = "bn-block-content";
+ const wrapper = document.createElement("span");
+ wrapper.dataset["userIds"] = '["u1"]';
+ wrapper.appendChild(withBox(document.createElement("span")));
+ block.appendChild(wrapper);
+ root.appendChild(block);
+
+ const observer = new MutationObserver(() => {});
+ observer.observe(root, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ scrollToFirstChange(root);
+ expect(observer.takeRecords()).toEqual([]);
+ observer.disconnect();
+ // The whole block, not the mark that was scrolled to.
+ expect(animate.mock.instances[0]).toBe(block);
+ expect(block.className).toBe("bn-block-content");
+ expect(block.hasAttribute("style")).toBe(false);
+ expect(animate).toHaveBeenCalledWith(expect.any(Array), {
+ duration: 1500,
+ fill: "none",
+ });
+
+ const animation = animate.mock.results[0]!.value;
+ scrollToFirstChange(root);
+ // Fire-and-forget highlight: the finished animation is cancelled by the
+ // next scroll instead of released via `onfinish`.
+ expect(animation.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("highlights the scrolled-to element when it is in no block", () => {
+ const root = makeRoot();
+ const wrapper = document.createElement("span");
+ wrapper.dataset["userIds"] = '["u1"]';
+ const content = withBox(document.createElement("span"));
+ wrapper.appendChild(content);
+ root.appendChild(wrapper);
+
+ scrollToFirstChange(root);
+ expect(animate.mock.instances.at(-1)).toBe(content);
+ });
+
+ /** A mark wrapper of the given element type around a content span. */
+ function makeMark(tag: "ins" | "del" | "span", content: Element): Element {
+ const wrapper = document.createElement(tag);
+ wrapper.dataset["userIds"] = '["u1"]';
+ wrapper.appendChild(content);
+ return wrapper;
+ }
+
+ it("skips marks with no layout box, such as inside a collapsed toggle", () => {
+ const root = makeRoot();
+ // A block-level mark whose whole subtree is hidden: nothing below it has a
+ // box, so descending would never find one.
+ const hiddenContent = withoutBox(document.createElement("span"));
+ hiddenContent.appendChild(withoutBox(document.createElement("div")));
+ root.appendChild(makeMark("ins", hiddenContent));
+ const visible = withBox(document.createElement("span"));
+ root.appendChild(makeMark("ins", visible));
+
+ expect(scrollToFirstChange(root)).toBe(true);
+ expect(scrollIntoView.mock.instances[0]).toBe(visible);
+ });
+
+ it("falls back to the containing block when every mark is hidden", () => {
+ const root = makeRoot();
+ // A collapsed toggle: its content is laid out, its child group is not.
+ const outer = withBox(document.createElement("div"));
+ outer.className = "bn-block-outer";
+ const block = withBox(document.createElement("div"));
+ block.className = "bn-block";
+ const toggleContent = withBox(document.createElement("div"));
+ toggleContent.className = "bn-block-content";
+ const hiddenGroup = withoutBox(document.createElement("div"));
+ hiddenGroup.className = "bn-block-group";
+ hiddenGroup.appendChild(
+ makeMark("ins", withoutBox(document.createElement("span"))),
+ );
+ block.append(toggleContent, hiddenGroup);
+ outer.appendChild(block);
+ root.appendChild(outer);
+
+ expect(scrollToFirstChange(root)).toBe(true);
+ expect(scrollIntoView.mock.instances[0]).toBe(toggleContent);
+ expect(animate.mock.instances.at(-1)).toBe(toggleContent);
+ });
+
+ it("returns false when a hidden mark has no laid-out ancestor below the root", () => {
+ const root = makeRoot();
+ root.appendChild(
+ makeMark("ins", withoutBox(document.createElement("span"))),
+ );
+
+ expect(scrollToFirstChange(root)).toBe(false);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ });
+
+ it("scrolls to the first change in document order, regardless of kind", () => {
+ const root = makeRoot();
+ root.appendChild(makeMark("del", withBox(document.createElement("span"))));
+ const formatted = withBox(document.createElement("span"));
+ root.appendChild(makeMark("span", formatted));
+
+ scrollToFirstChange(root);
+ expect(scrollIntoView.mock.instances[0]).toBe(
+ root.firstElementChild!.firstElementChild,
+ );
+
+ root.removeChild(root.firstElementChild!);
+ scrollToFirstChange(root);
+ expect(scrollIntoView.mock.instances[1]).toBe(formatted);
+ });
+
+ it("scrolls to and highlights the block's own content for a block-level mark", () => {
+ const root = makeRoot();
+ // `` > content span (display: contents) > .bn-block-outer > .bn-block >
+ // .bn-block-content, with a nested child block group after the content.
+ const content = withoutBox(document.createElement("span"));
+ const outer = withBox(document.createElement("div"));
+ outer.className = "bn-block-outer";
+ const block = withBox(document.createElement("div"));
+ block.className = "bn-block";
+ const blockContent = withBox(document.createElement("div"));
+ blockContent.className = "bn-block-content";
+ const childContent = withBox(document.createElement("div"));
+ childContent.className = "bn-block-content";
+ block.append(blockContent, childContent);
+ outer.appendChild(block);
+ content.appendChild(outer);
+ root.appendChild(makeMark("ins", content));
+
+ scrollToFirstChange(root);
+
+ expect(scrollIntoView.mock.instances[0]).toBe(blockContent);
+ expect(animate.mock.instances.at(-1)).toBe(blockContent);
+ expect(animate).toHaveBeenCalledTimes(1);
+ });
+
+ it("moves the highlight when a new preview scrolls elsewhere", () => {
+ const root = makeRoot();
+ const first = withBox(document.createElement("span"));
+ root.appendChild(makeMark("ins", first));
+ scrollToFirstChange(root);
+ expect(animate.mock.instances.at(-1)).toBe(first);
+
+ root.replaceChildren();
+ const second = withBox(document.createElement("span"));
+ root.appendChild(makeMark("ins", second));
+ scrollToFirstChange(root);
+
+ expect(animate.mock.results[0]!.value.cancel).toHaveBeenCalledOnce();
+ expect(animate.mock.instances.at(-1)).toBe(second);
+ // A late finish event from the cancelled pulse must not clear its successor.
+ animate.mock.results[0]!.value.onfinish?.();
+ scrollToFirstChange(root);
+ expect(animate.mock.results[1]!.value.cancel).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts
new file mode 100644
index 0000000000..e4232455b7
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts
@@ -0,0 +1,137 @@
+// Attribution wrappers carry `data-user-ids` but may be `display: contents`.
+// Their layout boxes are resolved locally to avoid depending on the `@y/*` stack.
+
+/** Duration of the transient block highlight. */
+const HIGHLIGHT_MS = 1500;
+
+/** Allow the preview layout to settle; animation frames pause in background tabs. */
+export const SCROLL_TO_FIRST_CHANGE_DELAY_MS = 200;
+
+function hasBox(element: Element): boolean {
+ const { width, height } = element.getBoundingClientRect();
+ return width !== 0 || height !== 0;
+}
+
+/** Descend through `display: contents` wrappers to the first laid-out node. */
+function findVisibleTarget(mark: Element): Element | undefined {
+ for (
+ let element: Element | null = mark;
+ element;
+ element = element.firstElementChild
+ ) {
+ if (hasBox(element)) {
+ return element;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Nearest ancestor of `mark` (below `root`) with a layout box: for a change
+ * hidden inside a collapsed toggle, that's the toggle block itself.
+ */
+function findVisibleAncestor(
+ mark: Element,
+ root: Element,
+): Element | undefined {
+ for (
+ let element = mark.parentElement;
+ element && element !== root && root.contains(element);
+ element = element.parentElement
+ ) {
+ if (hasBox(element)) {
+ return element;
+ }
+ }
+ return undefined;
+}
+
+/** Cancel the previous highlight when another change is revealed. */
+let activeHighlight: Animation | undefined;
+
+function highlight(block: Element) {
+ activeHighlight?.cancel();
+ // Fire-and-forget pulse: no DOM mutations for ProseMirror to observe. A
+ // finished animation stays referenced until the next scroll cancels it,
+ // which is a harmless no-op. Scrolling still works without WAAPI.
+ activeHighlight = block.animate?.(
+ [
+ {
+ backgroundColor: "color-mix(in srgb, #3e5de7 14%, transparent)",
+ boxShadow: "0 0 0 1px color-mix(in srgb, #3e5de7 45%, transparent)",
+ borderRadius: "4px",
+ easing: "ease-out",
+ },
+ {
+ backgroundColor: "transparent",
+ boxShadow: "0 0 0 1px transparent",
+ borderRadius: "4px",
+ },
+ ],
+ { duration: HIGHLIGHT_MS, fill: "none" },
+ );
+}
+
+function prefersReducedMotion(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false)
+ );
+}
+
+/**
+ * Scroll to the first change after preview layout settles. No-ops when
+ * disabled or when `isCurrent` reports the preview as superseded.
+ */
+export function scheduleScrollToFirstChange(
+ getRoot: () => Element | undefined,
+ options?: { enabled?: boolean; isCurrent?: () => boolean },
+): void {
+ if (options?.enabled === false) {
+ return;
+ }
+ // Let preview layout settle; timers also run in background tabs.
+ setTimeout(() => {
+ if (options?.isCurrent && !options.isCurrent()) {
+ return;
+ }
+ scrollToFirstChange(getRoot());
+ }, SCROLL_TO_FIRST_CHANGE_DELAY_MS);
+}
+
+/**
+ * Centre the first change in document order and highlight its block,
+ * respecting reduced motion. Changes hidden inside collapsed content fall
+ * back to their toggle block.
+ * @returns Whether a change was found and scrolled to.
+ */
+export function scrollToFirstChange(root: Element | undefined): boolean {
+ if (!root) {
+ return false;
+ }
+
+ let firstMark: Element | undefined;
+ let target: Element | undefined;
+ for (const mark of root.querySelectorAll("[data-user-ids]")) {
+ firstMark ??= mark;
+ target = findVisibleTarget(mark);
+ if (target) {
+ break;
+ }
+ }
+ target ??= firstMark ? findVisibleAncestor(firstMark, root) : undefined;
+ if (!target) {
+ return false;
+ }
+ // A block-level mark wraps the block; point at its content instead so the
+ // highlight doesn't span nested children.
+ target = target.querySelector(".bn-block-content") ?? target;
+
+ target.scrollIntoView({
+ block: "center",
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
+ });
+ highlight(target.closest(".bn-block-content") ?? target);
+
+ return true;
+}
diff --git a/packages/core/src/extensions/Versioning/state.test.ts b/packages/core/src/extensions/Versioning/state.test.ts
new file mode 100644
index 0000000000..b0b1e65aec
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/state.test.ts
@@ -0,0 +1,130 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from "vite-plus/test";
+
+import { findSnapshot, isReadOnly, resolveCompareTo } from "./state.js";
+import type {
+ LoadedVersioningList,
+ VersioningState,
+ VersionSnapshot,
+} from "./types.js";
+
+function snap(id: string, createdAt: number): VersionSnapshot {
+ return { id, createdAt };
+}
+
+function loadedList(
+ snapshots: VersionSnapshot[],
+ current: VersionSnapshot = snap("current", 30),
+): LoadedVersioningList {
+ return { loaded: true, current, snapshots };
+}
+
+function state(overrides?: Partial): VersioningState {
+ return {
+ list: { loaded: false },
+ view: { mode: "live" },
+ listing: false,
+ restoring: false,
+ ...overrides,
+ };
+}
+
+describe("isReadOnly", () => {
+ it("is read-only while previewing any mode", () => {
+ expect(
+ isReadOnly(state({ view: { mode: "snapshot", snapshotId: "a" } })),
+ ).toBe(true);
+ expect(isReadOnly(state({ view: { mode: "current" } }))).toBe(true);
+ expect(
+ isReadOnly(state({ view: { mode: "current", compareToId: "a" } })),
+ ).toBe(true);
+ });
+
+ it("is read-only while restoring, even when the view is live", () => {
+ expect(isReadOnly(state({ restoring: true }))).toBe(true);
+ });
+
+ it("is editable when live and not restoring", () => {
+ expect(isReadOnly(state())).toBe(false);
+ });
+});
+
+describe("findSnapshot", () => {
+ it("resolves the current row by id", () => {
+ const current = snap("current", 30);
+ expect(findSnapshot(loadedList([], current), "current")).toBe(current);
+ });
+
+ it("resolves a stored snapshot by id", () => {
+ const stored = snap("a", 10);
+ expect(findSnapshot(loadedList([stored]), "a")).toBe(stored);
+ });
+
+ it("accepts `{ id }` object identifiers", () => {
+ const current = snap("current", 30);
+ const stored = snap("a", 10);
+ expect(findSnapshot(loadedList([stored], current), { id: "current" })).toBe(
+ current,
+ );
+ expect(findSnapshot(loadedList([stored], current), { id: "a" })).toBe(
+ stored,
+ );
+ });
+
+ it("returns undefined for an unknown id", () => {
+ expect(findSnapshot(loadedList([snap("a", 10)]), "nope")).toBeUndefined();
+ });
+
+ it("returns undefined when the list is not loaded", () => {
+ expect(findSnapshot({ loaded: false }, "a")).toBeUndefined();
+ expect(findSnapshot({ loaded: false }, { id: "a" })).toBeUndefined();
+ });
+
+ it("returns undefined when no id is given", () => {
+ expect(
+ findSnapshot(loadedList([snap("a", 10)]), undefined),
+ ).toBeUndefined();
+ });
+
+ it("never reports the current row as a stored snapshot", () => {
+ // `current` is resolved by id even though it is not among `snapshots`.
+ const current = snap("current", 30);
+ const list = loadedList([snap("a", 10)], current);
+ expect(findSnapshot(list, "current")).toBe(current);
+ expect(list.snapshots).not.toContain(current);
+ });
+});
+
+describe("resolveCompareTo", () => {
+ it("returns undefined when no baseline is given", () => {
+ expect(
+ resolveCompareTo(loadedList([snap("a", 10)]), undefined),
+ ).toBeUndefined();
+ });
+
+ it("resolves a known id to its snapshot", () => {
+ const stored = snap("a", 10);
+ const current = snap("current", 30);
+ expect(resolveCompareTo(loadedList([stored], current), "a")).toBe(stored);
+ expect(resolveCompareTo(loadedList([stored], current), { id: "a" })).toBe(
+ stored,
+ );
+ expect(resolveCompareTo(loadedList([stored], current), "current")).toBe(
+ current,
+ );
+ });
+
+ it("throws for unknown string ids", () => {
+ expect(() => resolveCompareTo(loadedList([snap("a", 10)]), "nope")).toThrow(
+ "Snapshot not found: nope",
+ );
+ });
+
+ it("throws for unknown object ids", () => {
+ expect(() =>
+ resolveCompareTo(loadedList([snap("a", 10)]), { id: "nope" }),
+ ).toThrow("Snapshot not found: nope");
+ });
+});
diff --git a/packages/core/src/extensions/Versioning/state.ts b/packages/core/src/extensions/Versioning/state.ts
new file mode 100644
index 0000000000..734e64de0a
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/state.ts
@@ -0,0 +1,42 @@
+import type {
+ VersionSnapshot,
+ VersionSnapshotIdentifier,
+ VersioningList,
+ VersioningState,
+} from "./types.js";
+
+/** Previewing and restoring both hold the editor read-only. */
+export function isReadOnly(state: VersioningState): boolean {
+ return state.view.mode !== "live" || state.restoring;
+}
+
+/** The current row when `id` names it, otherwise a stored snapshot. */
+export function findSnapshot(
+ list: VersioningList,
+ id: VersionSnapshotIdentifier | undefined,
+): VersionSnapshot | undefined {
+ if (id === undefined || !list.loaded) {
+ return undefined;
+ }
+ const key = typeof id === "object" ? id.id : id;
+ return list.current.id === key
+ ? list.current
+ : list.snapshots.find((snapshot) => snapshot.id === key);
+}
+
+/** Resolve a comparison baseline, or `undefined` when none is given. */
+export function resolveCompareTo(
+ list: VersioningList,
+ compareTo: VersionSnapshotIdentifier | undefined,
+): VersionSnapshot | undefined {
+ if (compareTo === undefined) {
+ return undefined;
+ }
+ const snapshot = findSnapshot(list, compareTo);
+ if (snapshot === undefined) {
+ throw new Error(
+ `Snapshot not found: ${typeof compareTo === "object" ? compareTo.id : compareTo}`,
+ );
+ }
+ return snapshot;
+}
diff --git a/packages/core/src/extensions/Versioning/types.ts b/packages/core/src/extensions/Versioning/types.ts
new file mode 100644
index 0000000000..29cc33cb56
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/types.ts
@@ -0,0 +1,234 @@
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { User, UserStoreOrResolver } from "../../user/index.js";
+
+/** Metadata for a point in document history, managed by {@link VersioningEndpoints}. */
+export interface VersionSnapshot {
+ /** Backend-defined identifier (e.g. a YHub server timestamp or an in-memory id). */
+ id: string;
+
+ /** A version is named exactly when this is defined; used by the named-only filter. */
+ name?: string;
+
+ /**
+ * Last included edit, in Unix milliseconds. Timestamp-addressed backends use
+ * this to resolve content and attribution windows.
+ */
+ createdAt: number;
+
+ /**
+ * Raw author ids, resolved reactively through the extension's user store.
+ * Only displayed when {@link secondaryLabel} is unset.
+ */
+ by?: User["id"] | User["id"][];
+
+ /** Custom display label, taking precedence over author labels from {@link by}. */
+ secondaryLabel?: string;
+
+ /**
+ * Source of a restore. Carried explicitly because the source may no longer
+ * be listed (e.g. merged into another activity window).
+ */
+ restoredFrom?: {
+ /** The restored version's {@link VersionSnapshot.id}. */
+ id: string;
+ /** Source timestamp, displayed in the "Restored from" label. */
+ createdAt: number;
+ };
+
+ /** Application metadata for custom actions; BlockNote does not interpret it. */
+ metadata?: Record;
+}
+
+/** A version id or an object carrying it. */
+export type VersionSnapshotIdentifier = string | Pick;
+
+/**
+ * Preview content source:
+ * - `current`: serialize the live document. Snapshot metadata comes from the last
+ * listing and must not cap attribution windows for newer live edits.
+ * - `snapshot`: fetch stored content via {@link VersioningEndpoints.getContent}.
+ */
+export type PreviewTarget =
+ | { kind: "current"; snapshot: VersionSnapshot }
+ | { kind: "snapshot"; snapshot: VersionSnapshot };
+
+/** The editable live document, or a read-only preview with an optional baseline. */
+export type VersioningView =
+ | { mode: "live" }
+ | { mode: "current"; compareToId?: string }
+ | { mode: "snapshot"; snapshotId: string; compareToId?: string };
+
+/** The {@link VersioningView} members that put the editor in preview mode. */
+export type VersioningPreviewView = Exclude;
+
+/** What the extension is currently loading: a list fetch or a preview. */
+export type VersioningLoadingState =
+ | { type: "idle" }
+ | { type: "listing" }
+ | { type: "loading-preview"; view: VersioningPreviewView };
+
+/** Unknown until the first listing; once loaded, always contains a current row. */
+export type VersioningList =
+ | { loaded: false }
+ | {
+ loaded: true;
+ /** The live document's row — always the top row of the sidebar. */
+ current: VersionSnapshot;
+ /** Stored versions, newest first. Never contains {@link current}. */
+ snapshots: VersionSnapshot[];
+ };
+
+/** The {@link VersioningList} once {@link VersioningExtension.list} has run. */
+export type LoadedVersioningList = Extract;
+
+/** The {@link VersioningExtension}'s store state. */
+export type VersioningState = {
+ list: VersioningList;
+ view: VersioningView;
+ /** A list fetch is in flight; `getLoadingState` derives from this. */
+ listing: boolean;
+ /** The view whose content is still loading, if any; `getLoadingState` derives from this. */
+ loadingView?: VersioningPreviewView;
+ /** Holds the editor read-only during restore, including while the view is live. */
+ restoring: boolean;
+};
+
+/**
+ * Version storage, paired with a {@link PreviewController} for rendering.
+ * @typeParam Input - Live document handle supplied by `getCurrentDocument`.
+ * @typeParam Output - Serialized content fetched/restored and passed to the controller.
+ * @typeParam Attributions - Diff authorship data passed to the controller.
+ */
+export interface VersioningEndpoints<
+ Input = any,
+ Output = any,
+ Attributions = any,
+> {
+ /**
+ * Current metadata and stored versions (excluding current). The extension
+ * sorts stored versions newest-first.
+ */
+ list: () => Promise<{
+ current: VersionSnapshot;
+ snapshots: VersionSnapshot[];
+ }>;
+ /**
+ * Name the current version: capture content for snapshot backends, or label
+ * the newest edit for continuous-history backends. Omit to disable naming.
+ */
+ create?: (
+ /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument}. */
+ content: Input,
+ options: {
+ /** The name to give the current version. */
+ name?: string;
+ },
+ ) => Promise;
+ /**
+ * Restore a version and return content for {@link PreviewController.applyRestore}.
+ * Omit to disable restore.
+ */
+ restore?: (
+ /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument}. */
+ doc: Input,
+ /** The version to restore. */
+ snapshot: VersionSnapshot,
+ ) => Promise;
+ /** Fetch serialized content for {@link PreviewController.enterPreview}. */
+ getContent: (snapshot: VersionSnapshot) => Promise;
+ /**
+ * Fetch authorship for `compareTo → target`, passed to the preview controller.
+ * Omit for content comparisons without authorship.
+ */
+ getAttributions?: (
+ /** What's being previewed (the "new" side of the diff). */
+ target: PreviewTarget,
+ /** The baseline it's diffed against (the "old" side). */
+ compareTo?: VersionSnapshot,
+ ) => Promise;
+ /** Rename a version; undefined or empty clears its name. Omit to disable rename. */
+ rename?: (snapshot: VersionSnapshot, name?: string) => Promise;
+ /**
+ * Remove a stored version, or just its name on continuous-history backends.
+ * Omit to disable removal.
+ */
+ remove?: (snapshot: VersionSnapshot) => Promise;
+}
+
+/** Editor-aware endpoint factory. Type parameters match {@link VersioningEndpoints}. */
+export type VersioningEndpointsFactory<
+ Input = any,
+ Output = any,
+ Attributions = any,
+> = (
+ editor: BlockNoteEditor,
+) => VersioningEndpoints ;
+
+/**
+ * Renders content fetched by {@link VersioningEndpoints}.
+ * Type parameters match the endpoints' serialized content and authorship data.
+ */
+export interface PreviewController {
+ /** Whether comparisons are supported; defaults to true. Exposed as `canCompare`. */
+ supportsComparison?: boolean;
+ /**
+ * Render fetched content synchronously so superseded requests cannot render
+ * after exit. Put asynchronous work in the endpoints.
+ */
+ enterPreview: (
+ /** Content to preview ({@link Output}). */
+ snapshotContent: Output,
+ /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */
+ compareToContent?: Output,
+ /** Diff authorship; only meaningful with `compareToContent`. */
+ attributions?: Attributions,
+ /** Preview metadata for labels, separate from content and authorship. */
+ context?: { target: PreviewTarget; compareTo?: VersionSnapshot },
+ ) => undefined;
+ /** Exit preview mode and resume normal editing. */
+ exitPreview: () => void;
+ /** Apply the restore endpoint's content after exiting preview. Omit if unsupported. */
+ applyRestore?: (snapshotContent: Output) => void;
+}
+
+/**
+ * Bridges live editor data to version storage and rendering.
+ * Type parameters match {@link VersioningEndpoints}.
+ */
+export type VersioningExtensionOptions<
+ Input = any,
+ Output = any,
+ Attributions = any,
+> = {
+ /**
+ * Backend storage for versions.
+ */
+ endpoints:
+ | VersioningEndpoints
+ | VersioningEndpointsFactory ;
+ /**
+ * Controls how version previews and restores are rendered in the editor.
+ */
+ preview: PreviewController;
+ /**
+ * Live handle passed to create/restore (e.g. `Y.Node` or `Block[]`).
+ * Unlike `serializeCurrentContent`, this need not be detached or serialized.
+ */
+ getCurrentDocument: () => Input;
+ /**
+ * Serialize live content to the endpoint's output format for current previews.
+ * Omit to disable the extension's `previewCurrentVersion` method.
+ */
+ serializeCurrentContent?: () => Output | Promise;
+ /**
+ * Resolve {@link VersionSnapshot.by} for author labels; unresolved ids display raw.
+ * Accepts a resolver or a shared store to deduplicate loading across features.
+ */
+ resolveUsers?: UserStoreOrResolver;
+ /**
+ * Scroll to and highlight the first change after preview. Prefers insertions,
+ * then formatting, then deletions; collapsed changes use a visible ancestor.
+ * @default true
+ */
+ scrollToFirstChange?: boolean;
+};
diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts
index eb1d455e33..93a9d8c232 100644
--- a/packages/core/src/extensions/index.ts
+++ b/packages/core/src/extensions/index.ts
@@ -10,6 +10,7 @@ export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js";
export * from "./Placeholder/Placeholder.js";
export * from "./PositionMapping/PositionMapping.js";
export * from "./PreviousBlockType/PreviousBlockType.js";
+export * from "./ReadOnly/ReadOnly.js";
export * from "./ShowSelection/ShowSelection.js";
export * from "./SideMenu/SideMenu.js";
export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js";
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 1c19b810dd..2be9c1c274 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -396,9 +396,38 @@ export const ar: Dictionary = {
deleted: "محذوف",
inserted_by: (users: string) => `أُدرج بواسطة: ${users}`,
deleted_by: (users: string) => `حُذف بواسطة: ${users}`,
+ inserted_in: (version: string) => `أُدرج في: ${version}`,
+ deleted_in: (version: string) => `حُذف في: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`تغيير التنسيق (${formats}) بواسطة: ${users}`,
},
+ versioning: {
+ title: "السجل",
+ close: "إغلاق",
+ show_named_only: "إظهار الإصدارات المسماة فقط",
+ show_all: "إظهار جميع الإصدارات",
+ comparison_on: "تفعيل المقارنة",
+ comparison_off: "إيقاف المقارنة",
+ versions_list: "الإصدارات",
+ loading: "جارٍ تحميل الإصدارات",
+ empty: "لا توجد إصدارات بعد",
+ empty_named_only: "لا توجد إصدارات مسماة",
+ current_version: "الإصدار الحالي",
+ unnamed_version: "Unnamed version",
+ this_version: "هذا الإصدار",
+ before_restore: "قبل الاستعادة",
+ comparing_to: "مقارنة بـ",
+ restored_from: (date: string) => `تمت الاستعادة من ${date}`,
+ more_actions: "إجراءات أخرى",
+ version_name_input: "اسم الإصدار",
+ name_version_menuitem: "تسمية هذا الإصدار",
+ rename_menuitem: "إعادة تسمية",
+ compare_with_menuitem: "المقارنة بهذا الإصدار",
+ compare_since_beginning_menuitem: "المقارنة منذ البداية",
+ restore_menuitem: "استعادة",
+ delete_menuitem: "حذف",
+ action_failed: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
+ },
exporter: {
open_file: "فتح الملف",
open_video_file: "فتح الفيديو",
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 45ff9341d8..5cf27121bb 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -430,9 +430,38 @@ export const de: Dictionary = {
deleted: "Gelöscht",
inserted_by: (users: string) => `Eingefügt von: ${users}`,
deleted_by: (users: string) => `Gelöscht von: ${users}`,
+ inserted_in: (version: string) => `Eingefügt in: ${version}`,
+ deleted_in: (version: string) => `Gelöscht in: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Formatierungsänderung (${formats}) von: ${users}`,
},
+ versioning: {
+ title: "Verlauf",
+ close: "Schließen",
+ show_named_only: "Nur benannte Versionen anzeigen",
+ show_all: "Alle Versionen anzeigen",
+ comparison_on: "Vergleich einschalten",
+ comparison_off: "Vergleich ausschalten",
+ versions_list: "Versionen",
+ loading: "Versionen werden geladen",
+ empty: "Noch keine Versionen",
+ empty_named_only: "Keine benannten Versionen",
+ current_version: "Aktuelle Version",
+ unnamed_version: "Unnamed version",
+ this_version: "Diese Version",
+ before_restore: "Vor der Wiederherstellung",
+ comparing_to: "Verglichen mit",
+ restored_from: (date: string) => `Wiederhergestellt aus ${date}`,
+ more_actions: "Weitere Aktionen",
+ version_name_input: "Versionsname",
+ name_version_menuitem: "Diese Version benennen",
+ rename_menuitem: "Umbenennen",
+ compare_with_menuitem: "Mit dieser Version vergleichen",
+ compare_since_beginning_menuitem: "Seit Beginn vergleichen",
+ restore_menuitem: "Wiederherstellen",
+ delete_menuitem: "Löschen",
+ action_failed: "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
+ },
exporter: {
open_file: "Datei öffnen",
open_video_file: "Video öffnen",
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index 307ba90c22..c5ba301e91 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -411,9 +411,38 @@ export const en = {
deleted: "Deleted",
inserted_by: (users: string) => `Inserted by: ${users}`,
deleted_by: (users: string) => `Deleted by: ${users}`,
+ inserted_in: (version: string) => `Inserted in: ${version}`,
+ deleted_in: (version: string) => `Deleted in: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Formatting change (${formats}) by: ${users}`,
},
+ versioning: {
+ title: "History",
+ close: "Close",
+ show_named_only: "Show named versions only",
+ show_all: "Show all versions",
+ comparison_on: "Turn on comparison",
+ comparison_off: "Turn off comparison",
+ versions_list: "Versions",
+ loading: "Loading versions",
+ empty: "No versions yet",
+ empty_named_only: "No named versions",
+ current_version: "Current version",
+ unnamed_version: "Unnamed version",
+ this_version: "This version",
+ before_restore: "Before restore",
+ comparing_to: "Comparing to",
+ restored_from: (date: string) => `Restored from ${date}`,
+ more_actions: "More actions",
+ version_name_input: "Version name",
+ name_version_menuitem: "Name this version",
+ rename_menuitem: "Rename",
+ compare_with_menuitem: "Compare with this version",
+ compare_since_beginning_menuitem: "Compare since beginning",
+ restore_menuitem: "Restore",
+ delete_menuitem: "Delete",
+ action_failed: "Something went wrong. Please try again.",
+ },
exporter: {
open_file: "Open file",
open_video_file: "Open video",
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index b2c05ca6b2..781f9240e4 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -409,9 +409,38 @@ export const es: Dictionary = {
deleted: "Eliminado",
inserted_by: (users: string) => `Insertado por: ${users}`,
deleted_by: (users: string) => `Eliminado por: ${users}`,
+ inserted_in: (version: string) => `Insertado en: ${version}`,
+ deleted_in: (version: string) => `Eliminado en: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Cambio de formato (${formats}) por: ${users}`,
},
+ versioning: {
+ title: "Historial",
+ 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",
+ versions_list: "Versiones",
+ loading: "Cargando versiones",
+ empty: "Aún no hay versiones",
+ empty_named_only: "No hay versiones con nombre",
+ current_version: "Versión actual",
+ unnamed_version: "Unnamed version",
+ this_version: "Esta versión",
+ before_restore: "Antes de restaurar",
+ comparing_to: "Comparando con",
+ restored_from: (date: string) => `Restaurado desde ${date}`,
+ more_actions: "Más acciones",
+ version_name_input: "Nombre de la versión",
+ name_version_menuitem: "Nombrar esta versión",
+ rename_menuitem: "Cambiar nombre",
+ compare_with_menuitem: "Comparar con esta versión",
+ compare_since_beginning_menuitem: "Comparar desde el principio",
+ restore_menuitem: "Restaurar",
+ delete_menuitem: "Eliminar",
+ action_failed: "Algo salió mal. Inténtalo de nuevo.",
+ },
exporter: {
open_file: "Abrir archivo",
open_video_file: "Abrir vídeo",
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index 405cf87ddf..b61bcb48ca 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -380,9 +380,38 @@ export const fa = {
deleted: "حذف\u200cشده",
inserted_by: (users: string) => `درجشده توسط: ${users}`,
deleted_by: (users: string) => `حذفشده توسط: ${users}`,
+ inserted_in: (version: string) => `درجشده در: ${version}`,
+ deleted_in: (version: string) => `حذفشده در: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`تغییر قالببندی (${formats}) توسط: ${users}`,
},
+ versioning: {
+ title: "تاریخچه",
+ close: "بستن",
+ show_named_only: "فقط نسخههای نامگذاریشده نمایش داده شود",
+ show_all: "نمایش همه نسخهها",
+ comparison_on: "روشن کردن مقایسه",
+ comparison_off: "خاموش کردن مقایسه",
+ versions_list: "نسخهها",
+ loading: "در حال بارگذاری نسخهها",
+ empty: "هنوز نسخهای وجود ندارد",
+ empty_named_only: "نسخهای با نام وجود ندارد",
+ current_version: "نسخه فعلی",
+ unnamed_version: "Unnamed version",
+ this_version: "این نسخه",
+ before_restore: "پیش از بازیابی",
+ comparing_to: "مقایسه با",
+ restored_from: (date: string) => `بازیابیشده از ${date}`,
+ more_actions: "اقدامات بیشتر",
+ version_name_input: "نام نسخه",
+ name_version_menuitem: "نامگذاری این نسخه",
+ rename_menuitem: "تغییر نام",
+ compare_with_menuitem: "مقایسه با این نسخه",
+ compare_since_beginning_menuitem: "مقایسه از ابتدا",
+ restore_menuitem: "بازیابی",
+ delete_menuitem: "حذف",
+ action_failed: "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
+ },
exporter: {
open_file: "باز کردن فایل",
open_video_file: "باز کردن ویدیو",
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index 4807927655..0d12296351 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -457,9 +457,38 @@ export const fr: Dictionary = {
deleted: "Supprimé",
inserted_by: (users: string) => `Inséré par : ${users}`,
deleted_by: (users: string) => `Supprimé par : ${users}`,
+ inserted_in: (version: string) => `Inséré dans : ${version}`,
+ deleted_in: (version: string) => `Supprimé dans : ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Modification de mise en forme (${formats}) par : ${users}`,
},
+ versioning: {
+ title: "Historique",
+ 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",
+ versions_list: "Versions",
+ loading: "Chargement des versions",
+ empty: "Aucune version pour l'instant",
+ empty_named_only: "Aucune version nommée",
+ current_version: "Version actuelle",
+ unnamed_version: "Unnamed version",
+ this_version: "Cette version",
+ before_restore: "Avant la restauration",
+ comparing_to: "Comparé à",
+ restored_from: (date: string) => `Restauré depuis ${date}`,
+ more_actions: "Plus d'actions",
+ version_name_input: "Nom de la version",
+ name_version_menuitem: "Nommer cette version",
+ rename_menuitem: "Renommer",
+ compare_with_menuitem: "Comparer avec cette version",
+ compare_since_beginning_menuitem: "Comparer depuis le début",
+ restore_menuitem: "Restaurer",
+ delete_menuitem: "Supprimer",
+ action_failed: "Une erreur s'est produite. Veuillez réessayer.",
+ },
exporter: {
open_file: "Ouvrir le fichier",
open_video_file: "Ouvrir la vidéo",
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index 1b9338b77b..b8471e6e1f 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -411,9 +411,38 @@ export const he: Dictionary = {
deleted: "נמחק",
inserted_by: (users: string) => `נוסף על ידי: ${users}`,
deleted_by: (users: string) => `נמחק על ידי: ${users}`,
+ inserted_in: (version: string) => `נוסף בגרסה: ${version}`,
+ deleted_in: (version: string) => `נמחק בגרסה: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`שינוי עיצוב (${formats}) על ידי: ${users}`,
},
+ versioning: {
+ title: "היסטוריה",
+ close: "סגירה",
+ show_named_only: "הצג גרסאות בעלות שם בלבד",
+ show_all: "הצג את כל הגרסאות",
+ comparison_on: "הפעלת השוואה",
+ comparison_off: "כיבוי השוואה",
+ versions_list: "גרסאות",
+ loading: "טוען גרסאות",
+ empty: "אין עדיין גרסאות",
+ empty_named_only: "אין גרסאות עם שם",
+ current_version: "גרסה נוכחית",
+ unnamed_version: "Unnamed version",
+ this_version: "גרסה זו",
+ before_restore: "לפני השחזור",
+ comparing_to: "משווה מול",
+ restored_from: (date: string) => `שוחזר מ-${date}`,
+ more_actions: "פעולות נוספות",
+ version_name_input: "שם הגרסה",
+ name_version_menuitem: "מתן שם לגרסה זו",
+ rename_menuitem: "שינוי שם",
+ compare_with_menuitem: "השוואה לגרסה זו",
+ compare_since_beginning_menuitem: "השוואה מההתחלה",
+ restore_menuitem: "שחזור",
+ delete_menuitem: "מחיקה",
+ action_failed: "משהו השתבש. נסו שוב.",
+ },
exporter: {
open_file: "פתח קובץ",
open_video_file: "פתח וידאו",
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index 998a245f20..b707c7e14c 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -425,9 +425,38 @@ export const hr: Dictionary = {
deleted: "Izbrisano",
inserted_by: (users: string) => `Umetnuo/la: ${users}`,
deleted_by: (users: string) => `Izbrisao/la: ${users}`,
+ inserted_in: (version: string) => `Umetnuto u: ${version}`,
+ deleted_in: (version: string) => `Izbrisano u: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Promjena oblikovanja (${formats}) od: ${users}`,
},
+ versioning: {
+ title: "Povijest",
+ 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",
+ versions_list: "Verzije",
+ loading: "Učitavanje verzija",
+ empty: "Još nema verzija",
+ empty_named_only: "Nema imenovanih verzija",
+ current_version: "Trenutna verzija",
+ unnamed_version: "Unnamed version",
+ this_version: "Ova verzija",
+ before_restore: "Prije vraćanja",
+ comparing_to: "Uspoređuje se s",
+ restored_from: (date: string) => `Vraćeno s ${date}`,
+ more_actions: "Više radnji",
+ version_name_input: "Naziv verzije",
+ name_version_menuitem: "Imenuj ovu verziju",
+ rename_menuitem: "Preimenuj",
+ compare_with_menuitem: "Usporedi s ovom verzijom",
+ compare_since_beginning_menuitem: "Usporedi od početka",
+ restore_menuitem: "Vrati",
+ delete_menuitem: "Izbriši",
+ action_failed: "Nešto je pošlo po zlu. Pokušajte ponovno.",
+ },
exporter: {
open_file: "Otvori datoteku",
open_video_file: "Otvori videozapis",
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index e7effe3827..5a92305798 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -425,9 +425,38 @@ export const is: Dictionary = {
deleted: "Eytt",
inserted_by: (users: string) => `Sett inn af: ${users}`,
deleted_by: (users: string) => `Eytt af: ${users}`,
+ inserted_in: (version: string) => `Sett inn í: ${version}`,
+ deleted_in: (version: string) => `Eytt í: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Sniðbreyting (${formats}) af: ${users}`,
},
+ versioning: {
+ title: "Ferill",
+ 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",
+ versions_list: "Útgáfur",
+ loading: "Hleð útgáfum",
+ empty: "Engar útgáfur enn",
+ empty_named_only: "Engar nefndar útgáfur",
+ current_version: "Núverandi útgáfa",
+ unnamed_version: "Unnamed version",
+ this_version: "Þessi útgáfa",
+ before_restore: "Fyrir endurheimt",
+ comparing_to: "Borið saman við",
+ restored_from: (date: string) => `Endurheimt frá ${date}`,
+ more_actions: "Fleiri aðgerðir",
+ version_name_input: "Heiti útgáfu",
+ name_version_menuitem: "Nefna þessa útgáfu",
+ rename_menuitem: "Endurnefna",
+ compare_with_menuitem: "Bera saman við þessa útgáfu",
+ compare_since_beginning_menuitem: "Bera saman frá upphafi",
+ restore_menuitem: "Endurheimta",
+ delete_menuitem: "Eyða",
+ action_failed: "Eitthvað fór úrskeiðis. Reyndu aftur.",
+ },
exporter: {
open_file: "Opna skrá",
open_video_file: "Opna myndband",
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 782a3c7fc4..109216c392 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -433,9 +433,38 @@ export const it: Dictionary = {
deleted: "Eliminato",
inserted_by: (users: string) => `Inserito da: ${users}`,
deleted_by: (users: string) => `Eliminato da: ${users}`,
+ inserted_in: (version: string) => `Inserito in: ${version}`,
+ deleted_in: (version: string) => `Eliminato in: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Modifica formattazione (${formats}) da: ${users}`,
},
+ versioning: {
+ title: "Cronologia",
+ 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",
+ versions_list: "Versioni",
+ loading: "Caricamento delle versioni",
+ empty: "Nessuna versione",
+ empty_named_only: "Nessuna versione con nome",
+ current_version: "Versione corrente",
+ unnamed_version: "Unnamed version",
+ this_version: "Questa versione",
+ before_restore: "Prima del ripristino",
+ comparing_to: "Confronto con",
+ restored_from: (date: string) => `Ripristinato dal ${date}`,
+ more_actions: "Altre azioni",
+ version_name_input: "Nome della versione",
+ name_version_menuitem: "Assegna un nome a questa versione",
+ rename_menuitem: "Rinomina",
+ compare_with_menuitem: "Confronta con questa versione",
+ compare_since_beginning_menuitem: "Confronta dall'inizio",
+ restore_menuitem: "Ripristina",
+ delete_menuitem: "Elimina",
+ action_failed: "Qualcosa è andato storto. Riprova.",
+ },
exporter: {
open_file: "Apri file",
open_video_file: "Apri video",
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index 8bac14021d..24cdb5f272 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -451,9 +451,38 @@ export const ja: Dictionary = {
deleted: "削除済み",
inserted_by: (users: string) => `挿入者: ${users}`,
deleted_by: (users: string) => `削除者: ${users}`,
+ inserted_in: (version: string) => `挿入されたバージョン: ${version}`,
+ deleted_in: (version: string) => `削除されたバージョン: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`書式の変更 (${formats}) 変更者: ${users}`,
},
+ versioning: {
+ title: "履歴",
+ close: "閉じる",
+ show_named_only: "名前付きバージョンのみ表示",
+ show_all: "すべてのバージョンを表示",
+ comparison_on: "比較を有効にする",
+ comparison_off: "比較を無効にする",
+ versions_list: "バージョン",
+ loading: "バージョンを読み込んでいます",
+ empty: "バージョンはまだありません",
+ empty_named_only: "名前付きのバージョンはありません",
+ current_version: "現在のバージョン",
+ unnamed_version: "Unnamed version",
+ this_version: "このバージョン",
+ before_restore: "復元前",
+ comparing_to: "比較対象",
+ restored_from: (date: string) => `${date} から復元`,
+ more_actions: "その他の操作",
+ version_name_input: "バージョン名",
+ name_version_menuitem: "このバージョンに名前を付ける",
+ rename_menuitem: "名前を変更",
+ compare_with_menuitem: "このバージョンと比較",
+ compare_since_beginning_menuitem: "最初から比較",
+ restore_menuitem: "復元",
+ delete_menuitem: "削除",
+ action_failed: "問題が発生しました。もう一度お試しください。",
+ },
exporter: {
open_file: "ファイルを開く",
open_video_file: "動画を開く",
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index de94329b19..dc6eae7ed1 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -424,9 +424,38 @@ export const ko: Dictionary = {
deleted: "삭제됨",
inserted_by: (users: string) => `삽입한 사람: ${users}`,
deleted_by: (users: string) => `삭제한 사람: ${users}`,
+ inserted_in: (version: string) => `삽입된 버전: ${version}`,
+ deleted_in: (version: string) => `삭제된 버전: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`서식 변경 (${formats}) 변경한 사람: ${users}`,
},
+ versioning: {
+ title: "기록",
+ close: "닫기",
+ show_named_only: "이름이 지정된 버전만 표시",
+ show_all: "모든 버전 표시",
+ comparison_on: "비교 켜기",
+ comparison_off: "비교 끄기",
+ versions_list: "버전",
+ loading: "버전 불러오는 중",
+ empty: "아직 버전이 없습니다",
+ empty_named_only: "이름이 지정된 버전이 없습니다",
+ current_version: "현재 버전",
+ unnamed_version: "Unnamed version",
+ this_version: "이 버전",
+ before_restore: "복원 전",
+ comparing_to: "비교 대상",
+ restored_from: (date: string) => `${date}에서 복원됨`,
+ more_actions: "추가 작업",
+ version_name_input: "버전 이름",
+ name_version_menuitem: "이 버전의 이름 지정",
+ rename_menuitem: "이름 바꾸기",
+ compare_with_menuitem: "이 버전과 비교",
+ compare_since_beginning_menuitem: "처음부터 비교",
+ restore_menuitem: "복원",
+ delete_menuitem: "삭제",
+ action_failed: "문제가 발생했습니다. 다시 시도해 주세요.",
+ },
exporter: {
open_file: "파일 열기",
open_video_file: "동영상 열기",
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index a90210b572..ad0e24f17f 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -412,9 +412,38 @@ export const nl: Dictionary = {
deleted: "Verwijderd",
inserted_by: (users: string) => `Ingevoegd door: ${users}`,
deleted_by: (users: string) => `Verwijderd door: ${users}`,
+ inserted_in: (version: string) => `Ingevoegd in: ${version}`,
+ deleted_in: (version: string) => `Verwijderd in: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Opmaakwijziging (${formats}) door: ${users}`,
},
+ versioning: {
+ title: "Geschiedenis",
+ close: "Sluiten",
+ show_named_only: "Alleen benoemde versies tonen",
+ show_all: "Alle versies tonen",
+ comparison_on: "Vergelijking inschakelen",
+ comparison_off: "Vergelijking uitschakelen",
+ versions_list: "Versies",
+ loading: "Versies laden",
+ empty: "Nog geen versies",
+ empty_named_only: "Geen benoemde versies",
+ current_version: "Huidige versie",
+ unnamed_version: "Unnamed version",
+ this_version: "Deze versie",
+ before_restore: "Voor herstel",
+ comparing_to: "Vergeleken met",
+ restored_from: (date: string) => `Hersteld vanaf ${date}`,
+ more_actions: "Meer acties",
+ version_name_input: "Versienaam",
+ name_version_menuitem: "Deze versie een naam geven",
+ rename_menuitem: "Naam wijzigen",
+ compare_with_menuitem: "Vergelijken met deze versie",
+ compare_since_beginning_menuitem: "Vergelijken vanaf het begin",
+ restore_menuitem: "Herstellen",
+ delete_menuitem: "Verwijderen",
+ action_failed: "Er is iets misgegaan. Probeer het opnieuw.",
+ },
exporter: {
open_file: "Bestand openen",
open_video_file: "Video openen",
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 9ed6388dc7..06661d364f 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -429,9 +429,38 @@ export const no: Dictionary = {
deleted: "Slettet",
inserted_by: (users: string) => `Satt inn av: ${users}`,
deleted_by: (users: string) => `Slettet av: ${users}`,
+ inserted_in: (version: string) => `Satt inn i: ${version}`,
+ deleted_in: (version: string) => `Slettet i: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Formateringsendring (${formats}) av: ${users}`,
},
+ versioning: {
+ title: "Historikk",
+ close: "Lukk",
+ show_named_only: "Vis bare navngitte versjoner",
+ show_all: "Vis alle versjoner",
+ comparison_on: "Slå på sammenligning",
+ comparison_off: "Slå av sammenligning",
+ versions_list: "Versjoner",
+ loading: "Laster versjoner",
+ empty: "Ingen versjoner ennå",
+ empty_named_only: "Ingen navngitte versjoner",
+ current_version: "Gjeldende versjon",
+ unnamed_version: "Unnamed version",
+ this_version: "Denne versjonen",
+ before_restore: "Før gjenoppretting",
+ comparing_to: "Sammenligner med",
+ restored_from: (date: string) => `Gjenopprettet fra ${date}`,
+ more_actions: "Flere handlinger",
+ version_name_input: "Versjonsnavn",
+ name_version_menuitem: "Gi denne versjonen et navn",
+ rename_menuitem: "Gi nytt navn",
+ compare_with_menuitem: "Sammenlign med denne versjonen",
+ compare_since_beginning_menuitem: "Sammenlign fra begynnelsen",
+ restore_menuitem: "Gjenopprett",
+ delete_menuitem: "Slett",
+ action_failed: "Noe gikk galt. Prøv igjen.",
+ },
exporter: {
open_file: "Åpne fil",
open_video_file: "Åpne video",
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index 95751640b9..cc14e55637 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -402,9 +402,38 @@ export const pl: Dictionary = {
deleted: "Usunięto",
inserted_by: (users: string) => `Wstawione przez: ${users}`,
deleted_by: (users: string) => `Usunięte przez: ${users}`,
+ inserted_in: (version: string) => `Wstawione w: ${version}`,
+ deleted_in: (version: string) => `Usunięte w: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Zmiana formatowania (${formats}) przez: ${users}`,
},
+ versioning: {
+ title: "Historia",
+ 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",
+ versions_list: "Wersje",
+ loading: "Ładowanie wersji",
+ empty: "Brak wersji",
+ empty_named_only: "Brak nazwanych wersji",
+ current_version: "Bieżąca wersja",
+ unnamed_version: "Unnamed version",
+ this_version: "Ta wersja",
+ before_restore: "Przed przywróceniem",
+ comparing_to: "Porównanie z",
+ restored_from: (date: string) => `Przywrócono z ${date}`,
+ more_actions: "Więcej działań",
+ version_name_input: "Nazwa wersji",
+ name_version_menuitem: "Nazwij tę wersję",
+ rename_menuitem: "Zmień nazwę",
+ compare_with_menuitem: "Porównaj z tą wersją",
+ compare_since_beginning_menuitem: "Porównaj od początku",
+ restore_menuitem: "Przywróć",
+ delete_menuitem: "Usuń",
+ action_failed: "Coś poszło nie tak. Spróbuj ponownie.",
+ },
exporter: {
open_file: "Otwórz plik",
open_video_file: "Otwórz wideo",
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index 6914de9d2c..44677eb5f6 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -404,9 +404,38 @@ export const pt: Dictionary = {
deleted: "Excluído",
inserted_by: (users: string) => `Inserido por: ${users}`,
deleted_by: (users: string) => `Excluído por: ${users}`,
+ inserted_in: (version: string) => `Inserido em: ${version}`,
+ deleted_in: (version: string) => `Excluído em: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Alteração de formatação (${formats}) por: ${users}`,
},
+ versioning: {
+ title: "Histórico",
+ 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",
+ versions_list: "Versões",
+ loading: "Carregando versões",
+ empty: "Ainda não há versões",
+ empty_named_only: "Não há versões nomeadas",
+ current_version: "Versão atual",
+ unnamed_version: "Unnamed version",
+ this_version: "Esta versão",
+ before_restore: "Antes da restauração",
+ comparing_to: "Comparando com",
+ restored_from: (date: string) => `Restaurado de ${date}`,
+ more_actions: "Mais ações",
+ version_name_input: "Nome da versão",
+ name_version_menuitem: "Nomear esta versão",
+ rename_menuitem: "Renomear",
+ compare_with_menuitem: "Comparar com esta versão",
+ compare_since_beginning_menuitem: "Comparar desde o início",
+ restore_menuitem: "Restaurar",
+ delete_menuitem: "Excluir",
+ action_failed: "Algo deu errado. Tente novamente.",
+ },
exporter: {
open_file: "Abrir arquivo",
open_video_file: "Abrir vídeo",
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index db116a3c4c..2490a3be5b 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -455,9 +455,38 @@ export const ru: Dictionary = {
deleted: "Удалено",
inserted_by: (users: string) => `Вставлено: ${users}`,
deleted_by: (users: string) => `Удалено: ${users}`,
+ inserted_in: (version: string) => `Вставлено в версии: ${version}`,
+ deleted_in: (version: string) => `Удалено в версии: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Изменение форматирования (${formats}): ${users}`,
},
+ versioning: {
+ title: "История",
+ close: "Закрыть",
+ show_named_only: "Показывать только именованные версии",
+ show_all: "Показывать все версии",
+ comparison_on: "Включить сравнение",
+ comparison_off: "Выключить сравнение",
+ versions_list: "Версии",
+ loading: "Загрузка версий",
+ empty: "Пока нет версий",
+ empty_named_only: "Нет именованных версий",
+ current_version: "Текущая версия",
+ unnamed_version: "Unnamed version",
+ this_version: "Эта версия",
+ before_restore: "До восстановления",
+ comparing_to: "Сравнение с",
+ restored_from: (date: string) => `Восстановлено из ${date}`,
+ more_actions: "Другие действия",
+ version_name_input: "Название версии",
+ name_version_menuitem: "Назвать эту версию",
+ rename_menuitem: "Переименовать",
+ compare_with_menuitem: "Сравнить с этой версией",
+ compare_since_beginning_menuitem: "Сравнить с начала",
+ restore_menuitem: "Восстановить",
+ delete_menuitem: "Удалить",
+ action_failed: "Что-то пошло не так. Попробуйте ещё раз.",
+ },
exporter: {
open_file: "Открыть файл",
open_video_file: "Открыть видео",
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index f53c4c39d1..810993328a 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -409,9 +409,38 @@ export const sk = {
deleted: "Odstránené",
inserted_by: (users: string) => `Vložil: ${users}`,
deleted_by: (users: string) => `Odstránil: ${users}`,
+ inserted_in: (version: string) => `Vložené vo verzii: ${version}`,
+ deleted_in: (version: string) => `Odstránené vo verzii: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Zmena formátovania (${formats}) od: ${users}`,
},
+ versioning: {
+ title: "História",
+ 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",
+ versions_list: "Verzie",
+ loading: "Načítavajú sa verzie",
+ empty: "Zatiaľ žiadne verzie",
+ empty_named_only: "Žiadne pomenované verzie",
+ current_version: "Aktuálna verzia",
+ unnamed_version: "Unnamed version",
+ this_version: "Táto verzia",
+ before_restore: "Pred obnovením",
+ comparing_to: "Porovnáva sa s",
+ restored_from: (date: string) => `Obnovené z ${date}`,
+ more_actions: "Ďalšie akcie",
+ version_name_input: "Názov verzie",
+ name_version_menuitem: "Pomenovať túto verziu",
+ rename_menuitem: "Premenovať",
+ compare_with_menuitem: "Porovnať s touto verziou",
+ compare_since_beginning_menuitem: "Porovnať od začiatku",
+ restore_menuitem: "Obnoviť",
+ delete_menuitem: "Odstrániť",
+ action_failed: "Niečo sa pokazilo. Skúste to znova.",
+ },
exporter: {
open_file: "Otvoriť súbor",
open_video_file: "Otvoriť video",
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index e6101c8f69..97c3e742bb 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -435,9 +435,38 @@ export const uk: Dictionary = {
deleted: "Видалено",
inserted_by: (users: string) => `Вставлено користувачем: ${users}`,
deleted_by: (users: string) => `Видалено користувачем: ${users}`,
+ inserted_in: (version: string) => `Вставлено у версії: ${version}`,
+ deleted_in: (version: string) => `Видалено у версії: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Зміна форматування (${formats}) користувачем: ${users}`,
},
+ versioning: {
+ title: "Історія",
+ close: "Закрити",
+ show_named_only: "Показувати лише названі версії",
+ show_all: "Показувати всі версії",
+ comparison_on: "Увімкнути порівняння",
+ comparison_off: "Вимкнути порівняння",
+ versions_list: "Версії",
+ loading: "Завантаження версій",
+ empty: "Версій ще немає",
+ empty_named_only: "Немає іменованих версій",
+ current_version: "Поточна версія",
+ unnamed_version: "Unnamed version",
+ this_version: "Ця версія",
+ before_restore: "До відновлення",
+ comparing_to: "Порівняння з",
+ restored_from: (date: string) => `Відновлено з ${date}`,
+ more_actions: "Інші дії",
+ version_name_input: "Назва версії",
+ name_version_menuitem: "Назвати цю версію",
+ rename_menuitem: "Перейменувати",
+ compare_with_menuitem: "Порівняти з цією версією",
+ compare_since_beginning_menuitem: "Порівняти від початку",
+ restore_menuitem: "Відновити",
+ delete_menuitem: "Видалити",
+ action_failed: "Щось пішло не так. Спробуйте ще раз.",
+ },
exporter: {
open_file: "Відкрити файл",
open_video_file: "Відкрити відео",
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index 23b0f4f1a7..a2b83df56d 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -445,9 +445,38 @@ export const uz: Dictionary = {
deleted: "O'chirildi",
inserted_by: (users: string) => `Qo'shgan: ${users}`,
deleted_by: (users: string) => `O'chirgan: ${users}`,
+ inserted_in: (version: string) => `Qo'shilgan versiya: ${version}`,
+ deleted_in: (version: string) => `O'chirilgan versiya: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`,
},
+ versioning: {
+ title: "Tarix",
+ 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",
+ versions_list: "Versiyalar",
+ loading: "Versiyalar yuklanmoqda",
+ empty: "Hozircha versiyalar yo'q",
+ empty_named_only: "Nomlangan versiyalar yo'q",
+ current_version: "Joriy versiya",
+ unnamed_version: "Unnamed version",
+ this_version: "Ushbu versiya",
+ before_restore: "Tiklashdan oldin",
+ comparing_to: "Taqqoslanmoqda",
+ restored_from: (date: string) => `${date} dan tiklangan`,
+ more_actions: "Boshqa amallar",
+ version_name_input: "Versiya nomi",
+ name_version_menuitem: "Bu versiyaga nom berish",
+ rename_menuitem: "Nomini o'zgartirish",
+ compare_with_menuitem: "Shu versiya bilan taqqoslash",
+ compare_since_beginning_menuitem: "Boshidan taqqoslash",
+ restore_menuitem: "Tiklash",
+ delete_menuitem: "O'chirish",
+ action_failed: "Xatolik yuz berdi. Qayta urinib ko‘ring.",
+ },
exporter: {
open_file: "Faylni ochish",
open_video_file: "Videoni ochish",
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index d52db4d48d..2faaa174d5 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -410,9 +410,38 @@ export const vi: Dictionary = {
deleted: "Đã xóa",
inserted_by: (users: string) => `Được chèn bởi: ${users}`,
deleted_by: (users: string) => `Được xóa bởi: ${users}`,
+ inserted_in: (version: string) => `Được chèn trong: ${version}`,
+ deleted_in: (version: string) => `Được xóa trong: ${version}`,
formatting_change_by: (formats: string, users: string) =>
`Thay đổi định dạng (${formats}) bởi: ${users}`,
},
+ versioning: {
+ title: "Lịch sử",
+ 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",
+ versions_list: "Phiên bản",
+ loading: "Đang tải 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",
+ current_version: "Phiên bản hiện tại",
+ unnamed_version: "Unnamed version",
+ this_version: "Phiên bản này",
+ before_restore: "Trước khi khôi phục",
+ comparing_to: "Đang so sánh với",
+ restored_from: (date: string) => `Khôi phục từ ${date}`,
+ more_actions: "Thao tác khác",
+ version_name_input: "Tên phiên bản",
+ name_version_menuitem: "Đặt tên cho phiên bản này",
+ rename_menuitem: "Đổi tên",
+ compare_with_menuitem: "So sánh với phiên bản này",
+ compare_since_beginning_menuitem: "So sánh từ đầu",
+ restore_menuitem: "Khôi phục",
+ delete_menuitem: "Xóa",
+ action_failed: "Đã xảy ra lỗi. Vui lòng thử lại.",
+ },
exporter: {
open_file: "Mở tệp",
open_video_file: "Mở video",
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index 0aba71ead4..6bb8fd51ce 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -452,9 +452,38 @@ export const zhTW: Dictionary = {
deleted: "已刪除",
inserted_by: (users: string) => `插入者:${users}`,
deleted_by: (users: string) => `刪除者:${users}`,
+ inserted_in: (version: string) => `插入於:${version}`,
+ deleted_in: (version: string) => `刪除於:${version}`,
formatting_change_by: (formats: string, users: string) =>
`格式變更(${formats}),變更者:${users}`,
},
+ versioning: {
+ title: "版本紀錄",
+ close: "關閉",
+ show_named_only: "僅顯示已命名的版本",
+ show_all: "顯示所有版本",
+ comparison_on: "開啟比較",
+ comparison_off: "關閉比較",
+ versions_list: "版本",
+ loading: "正在載入版本",
+ empty: "尚無版本",
+ empty_named_only: "尚無命名版本",
+ current_version: "目前版本",
+ unnamed_version: "Unnamed version",
+ this_version: "此版本",
+ before_restore: "還原前",
+ comparing_to: "比較對象",
+ restored_from: (date: string) => `已從 ${date} 還原`,
+ more_actions: "更多操作",
+ version_name_input: "版本名稱",
+ name_version_menuitem: "為此版本命名",
+ rename_menuitem: "重新命名",
+ compare_with_menuitem: "與此版本比較",
+ compare_since_beginning_menuitem: "從開頭開始比較",
+ restore_menuitem: "還原",
+ delete_menuitem: "刪除",
+ action_failed: "發生錯誤,請再試一次。",
+ },
exporter: {
open_file: "開啟檔案",
open_video_file: "開啟影片",
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index 0017c86672..ff25d2f849 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -452,9 +452,38 @@ export const zh: Dictionary = {
deleted: "已删除",
inserted_by: (users: string) => `插入者:${users}`,
deleted_by: (users: string) => `删除者:${users}`,
+ inserted_in: (version: string) => `插入于:${version}`,
+ deleted_in: (version: string) => `删除于:${version}`,
formatting_change_by: (formats: string, users: string) =>
`格式更改(${formats}),更改者:${users}`,
},
+ versioning: {
+ title: "历史记录",
+ close: "关闭",
+ show_named_only: "仅显示已命名的版本",
+ show_all: "显示所有版本",
+ comparison_on: "开启对比",
+ comparison_off: "关闭对比",
+ versions_list: "版本",
+ loading: "正在加载版本",
+ empty: "暂无版本",
+ empty_named_only: "暂无命名版本",
+ current_version: "当前版本",
+ unnamed_version: "Unnamed version",
+ this_version: "此版本",
+ before_restore: "恢复前",
+ comparing_to: "对比对象",
+ restored_from: (date: string) => `恢复自 ${date}`,
+ more_actions: "更多操作",
+ version_name_input: "版本名称",
+ name_version_menuitem: "命名此版本",
+ rename_menuitem: "重命名",
+ compare_with_menuitem: "与此版本对比",
+ compare_since_beginning_menuitem: "从开头开始对比",
+ restore_menuitem: "恢复",
+ delete_menuitem: "删除",
+ action_failed: "出错了,请重试。",
+ },
exporter: {
open_file: "打开文件",
open_video_file: "打开视频",
diff --git a/packages/core/src/user/userColors.test.ts b/packages/core/src/user/userColors.test.ts
new file mode 100644
index 0000000000..2d130d1257
--- /dev/null
+++ b/packages/core/src/user/userColors.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import type { User } from "./UserStore.js";
+import { createUserStore } from "./UserStore.js";
+import {
+ colorsForUserIds,
+ fallbackColorForUserId,
+ userColorPalette,
+ userMarkColors,
+} from "./userColors.js";
+
+describe("userColorPalette", () => {
+ it("contains no red", () => {
+ // The palette tints insertions as well as deletions, so a red entry would
+ // make one author's additions read as errors. Guard the property rather
+ // than the exact hexes: red is any entry whose hue is near 0°/360°.
+ for (const { light, dark } of userColorPalette) {
+ for (const color of [light, dark]) {
+ const [r, g, b] = [1, 3, 5].map((offset) =>
+ parseInt(color.slice(offset, offset + 2), 16),
+ );
+ const isRed = r > g + 40 && r > b + 40;
+ expect(isRed, `${color} reads as red`).toBe(false);
+ }
+ }
+ });
+
+ it("assigns a stable entry per user id", () => {
+ expect(fallbackColorForUserId("alice")).toEqual(
+ fallbackColorForUserId("alice"),
+ );
+ expect(userColorPalette).toContainEqual(fallbackColorForUserId("alice"));
+ });
+});
+
+describe("userMarkColors", () => {
+ it("is undefined for a user with no color", () => {
+ expect(userMarkColors(undefined)).toBeUndefined();
+ expect(userMarkColors({})).toBeUndefined();
+ });
+
+ it("uses both colors when the app supplies both", () => {
+ expect(userMarkColors({ color: "#123456", colorLight: "#abcdef" })).toEqual(
+ {
+ light: "#abcdef",
+ dark: "#123456",
+ },
+ );
+ });
+
+ it("derives the light tint when only `color` is set", () => {
+ expect(userMarkColors({ color: "#123456" })).toEqual({
+ light: "color-mix(in srgb, #123456 30%, white)",
+ dark: "#123456",
+ });
+ });
+});
+
+describe("colorsForUserIds", () => {
+ it("falls back to the first palette entry with no ids", () => {
+ const store = createUserStore(async () => []);
+ expect(colorsForUserIds(store, undefined)).toEqual(userColorPalette[0]);
+ expect(colorsForUserIds(store, [])).toEqual(userColorPalette[0]);
+ });
+
+ it("falls back to the id's palette entry for an unresolved user", () => {
+ const store = createUserStore(async () => []);
+ expect(colorsForUserIds(store, ["alice"])).toEqual(
+ fallbackColorForUserId("alice"),
+ );
+ });
+
+ it("uses the resolved user's own colors, deriving the tint when needed", async () => {
+ const store = createUserStore(async (ids: string[]) =>
+ ids.map((id) => ({
+ id,
+ username: id,
+ avatarUrl: "",
+ color: id === "both" ? "#123456" : "#654321",
+ ...(id === "both" ? { colorLight: "#abcdef" } : {}),
+ })),
+ );
+ await store.loadUsers(["both", "dark-only"]);
+
+ expect(colorsForUserIds(store, ["both"])).toEqual({
+ light: "#abcdef",
+ dark: "#123456",
+ });
+ expect(colorsForUserIds(store, ["dark-only"])).toEqual({
+ light: "color-mix(in srgb, #654321 30%, white)",
+ dark: "#654321",
+ });
+ });
+});
diff --git a/packages/core/src/user/userColors.ts b/packages/core/src/user/userColors.ts
index b63e909238..fdad687cc9 100644
--- a/packages/core/src/user/userColors.ts
+++ b/packages/core/src/user/userColors.ts
@@ -1,5 +1,5 @@
import { digestString } from "lib0/hash/fnv1a";
-import type { UserStore } from "./UserStore.js";
+import type { User, UserStore } from "./UserStore.js";
/**
* Deterministic hash of a string to an unsigned 32-bit integer.
@@ -12,13 +12,21 @@ const hashStr = (s: string): number => {
return Math.abs(hash);
};
-/** Fallback palette used when a user has no resolved color of their own. */
+/**
+ * Fallback palette used when a user has no resolved color of their own.
+ *
+ * Deliberately red-free: these colors tint *insertions* as well as deletions,
+ * and a red insertion reads as an error rather than as one author's
+ * contribution. The hues are spread far enough apart to stay distinguishable
+ * for the most common forms of colour-vision deficiency.
+ */
export const userColorPalette: Array<{ light: string; dark: string }> = [
- { light: "#fff0c2", dark: "#8a6d1a" },
- { light: "#fcc9c3", dark: "#8a2e24" },
- { light: "#d4e8eb", dark: "#4a7178" },
- { light: "#c2eeff", dark: "#1a6e8a" },
- { light: "#bef3ff", dark: "#0a7a8a" },
+ { light: "#fff0c2", dark: "#8a6d1a" }, // amber
+ { light: "#dcdefc", dark: "#3b3f9c" }, // indigo
+ { light: "#c9efe9", dark: "#0f6e62" }, // teal
+ { light: "#c9dcff", dark: "#1e4fb0" }, // blue
+ { light: "#eadcfb", dark: "#6b2fa3" }, // violet
+ { light: "#dfe4ea", dark: "#46525f" }, // slate
];
/** The deterministic {@link userColorPalette} entry for a single user id. */
@@ -28,7 +36,28 @@ export const fallbackColorForUserId = (
userColorPalette[hashStr(id) % userColorPalette.length];
/**
- * The (first) user's resolved color from the {@link UserStore}, or their
+ * A user's own mark colors, or `undefined` when they have none.
+ *
+ * `color` is the saturated color the app already uses for that user (cursors,
+ * avatars); `colorLight` is the pale background a mark is highlighted with. Most
+ * applications only set the former, so derive the latter rather than fall back
+ * to a palette entry that has nothing to do with the user's actual color — a
+ * user whose cursor is green shouldn't have amber marks.
+ */
+export const userMarkColors = (
+ user: Pick | undefined,
+): { light: string; dark: string } | undefined => {
+ if (!user?.color) {
+ return undefined;
+ }
+ return {
+ light: user.colorLight ?? `color-mix(in srgb, ${user.color} 30%, white)`,
+ dark: user.color,
+ };
+};
+
+/**
+ * The (first) user's {@link userMarkColors}, or their
* {@link fallbackColorForUserId} palette entry. Used where a concrete color
* string is needed (the portaled hover tooltip); marks themselves use the
* cascaded {@link userColorVarNames} properties instead.
@@ -41,11 +70,10 @@ export const colorsForUserIds = (
return userColorPalette[0];
}
const firstId = userIds[0];
- const user = userStore.getUser(firstId);
- if (user?.color && user.colorLight) {
- return { light: user.colorLight, dark: user.color };
- }
- return fallbackColorForUserId(firstId);
+ return (
+ userMarkColors(userStore.getUser(firstId)) ??
+ fallbackColorForUserId(firstId)
+ );
};
/**
diff --git a/packages/core/src/y/comments/RESTYjsThreadStore.ts b/packages/core/src/y/comments/RESTYjsThreadStore.ts
index 7841f453f4..d14d69d13a 100644
--- a/packages/core/src/y/comments/RESTYjsThreadStore.ts
+++ b/packages/core/src/y/comments/RESTYjsThreadStore.ts
@@ -21,7 +21,7 @@ export class RESTYjsThreadStore extends YjsThreadStoreBase {
constructor(
private readonly BASE_URL: string,
private readonly headers: Record,
- threadsYType: Y.Type,
+ threadsYType: Y.Node,
auth: ThreadStoreAuth,
) {
super(threadsYType, auth);
diff --git a/packages/core/src/y/comments/YjsThreadStore.test.ts b/packages/core/src/y/comments/YjsThreadStore.test.ts
index 84ce8c47f4..9683393f55 100644
--- a/packages/core/src/y/comments/YjsThreadStore.test.ts
+++ b/packages/core/src/y/comments/YjsThreadStore.test.ts
@@ -14,7 +14,7 @@ vi.mock("lib0/random", async (importOriginal) => ({
describe("YjsThreadStore (@y/y v14)", () => {
let store: YjsThreadStore;
let doc: Y.Doc;
- let threadsYType: Y.Type;
+ let threadsYType: Y.Node;
beforeEach(() => {
// Reset mocks and create fresh instances
diff --git a/packages/core/src/y/comments/YjsThreadStore.ts b/packages/core/src/y/comments/YjsThreadStore.ts
index 0a9b09a676..82ab3433a7 100644
--- a/packages/core/src/y/comments/YjsThreadStore.ts
+++ b/packages/core/src/y/comments/YjsThreadStore.ts
@@ -29,7 +29,7 @@ import {
export class YjsThreadStore extends YjsThreadStoreBase {
constructor(
private readonly userId: string,
- threadsYType: Y.Type,
+ threadsYType: Y.Node,
auth: ThreadStoreAuth,
) {
super(threadsYType, auth);
@@ -98,7 +98,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
threadId: string;
}) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
@@ -121,7 +121,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
body: options.comment.body,
};
- (yThread.getAttr("comments") as Y.Type).push([commentToYType(comment)]);
+ (yThread.getAttr("comments") as Y.Node).push([commentToYType(comment)]);
yThread.setAttr("updatedAt", new Date().getTime());
return comment;
@@ -138,23 +138,23 @@ export class YjsThreadStore extends YjsThreadStoreBase {
commentId: string;
}) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
}
- const commentsType = yThread.getAttr("comments") as Y.Type;
+ const commentsType = yThread.getAttr("comments") as Y.Node;
const yCommentIndex = yTypeFindIndex(
commentsType,
- (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ (comment) => (comment as Y.Node).getAttr("id") === options.commentId,
);
if (yCommentIndex === -1) {
throw new Error("Comment not found");
}
- const yComment = commentsType.get(yCommentIndex) as Y.Type;
+ const yComment = commentsType.get(yCommentIndex) as Y.Node;
if (!this.auth.canUpdateComment(yTypeToComment(yComment))) {
throw new Error("Not authorized");
@@ -173,23 +173,23 @@ export class YjsThreadStore extends YjsThreadStoreBase {
softDelete?: boolean;
}) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
}
- const commentsType = yThread.getAttr("comments") as Y.Type;
+ const commentsType = yThread.getAttr("comments") as Y.Node;
const yCommentIndex = yTypeFindIndex(
commentsType,
- (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ (comment) => (comment as Y.Node).getAttr("id") === options.commentId,
);
if (yCommentIndex === -1) {
throw new Error("Comment not found");
}
- const yComment = commentsType.get(yCommentIndex) as Y.Type;
+ const yComment = commentsType.get(yCommentIndex) as Y.Node;
if (!this.auth.canDeleteComment(yTypeToComment(yComment))) {
throw new Error("Not authorized");
@@ -209,7 +209,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
if (
commentsType
.toArray()
- .every((comment) => (comment as Y.Type).getAttr("deletedAt"))
+ .every((comment) => (comment as Y.Node).getAttr("deletedAt"))
) {
// all comments deleted
if (options.softDelete) {
@@ -226,7 +226,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
public deleteThread = this.transact((options: { threadId: string }) => {
if (
!this.auth.canDeleteThread(
- yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Type),
+ yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Node),
)
) {
throw new Error("Not authorized");
@@ -237,7 +237,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
public resolveThread = this.transact((options: { threadId: string }) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
@@ -254,7 +254,7 @@ export class YjsThreadStore extends YjsThreadStoreBase {
public unresolveThread = this.transact((options: { threadId: string }) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
@@ -271,23 +271,23 @@ export class YjsThreadStore extends YjsThreadStoreBase {
public addReaction = this.transact(
(options: { threadId: string; commentId: string; emoji: string }) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
}
- const commentsType = yThread.getAttr("comments") as Y.Type;
+ const commentsType = yThread.getAttr("comments") as Y.Node;
const yCommentIndex = yTypeFindIndex(
commentsType,
- (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ (comment) => (comment as Y.Node).getAttr("id") === options.commentId,
);
if (yCommentIndex === -1) {
throw new Error("Comment not found");
}
- const yComment = commentsType.get(yCommentIndex) as Y.Type;
+ const yComment = commentsType.get(yCommentIndex) as Y.Node;
if (!this.auth.canAddReaction(yTypeToComment(yComment), options.emoji)) {
throw new Error("Not authorized");
@@ -297,13 +297,13 @@ export class YjsThreadStore extends YjsThreadStoreBase {
const key = `${this.userId}-${options.emoji}`;
- const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type;
+ const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node;
if (reactionsByUser.hasAttr(key)) {
// already exists
return;
} else {
- const reaction = new Y.Type();
+ const reaction = new Y.Node();
reaction.setAttr("emoji", options.emoji);
reaction.setAttr("createdAt", date.getTime());
reaction.setAttr("userId", this.userId);
@@ -315,23 +315,23 @@ export class YjsThreadStore extends YjsThreadStoreBase {
public deleteReaction = this.transact(
(options: { threadId: string; commentId: string; emoji: string }) => {
const yThread = this.threadsYType.getAttr(options.threadId) as
- | Y.Type
+ | Y.Node
| undefined;
if (!yThread) {
throw new Error("Thread not found");
}
- const commentsType = yThread.getAttr("comments") as Y.Type;
+ const commentsType = yThread.getAttr("comments") as Y.Node;
const yCommentIndex = yTypeFindIndex(
commentsType,
- (comment) => (comment as Y.Type).getAttr("id") === options.commentId,
+ (comment) => (comment as Y.Node).getAttr("id") === options.commentId,
);
if (yCommentIndex === -1) {
throw new Error("Comment not found");
}
- const yComment = commentsType.get(yCommentIndex) as Y.Type;
+ const yComment = commentsType.get(yCommentIndex) as Y.Node;
if (
!this.auth.canDeleteReaction(yTypeToComment(yComment), options.emoji)
@@ -341,14 +341,14 @@ export class YjsThreadStore extends YjsThreadStoreBase {
const key = `${this.userId}-${options.emoji}`;
- const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type;
+ const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node;
reactionsByUser.deleteAttr(key);
},
);
}
-function yTypeFindIndex(yType: Y.Type, predicate: (item: any) => boolean) {
+function yTypeFindIndex(yType: Y.Node, predicate: (item: any) => boolean) {
for (let i = 0; i < yType.length; i++) {
if (predicate(yType.get(i))) {
return i;
diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts
index b62c2e1811..c76c890f18 100644
--- a/packages/core/src/y/comments/YjsThreadStoreBase.ts
+++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts
@@ -10,7 +10,7 @@ import { yTypeToThread } from "./yjsHelpers.js";
*/
export abstract class YjsThreadStoreBase extends ThreadStore {
constructor(
- protected readonly threadsYType: Y.Type,
+ protected readonly threadsYType: Y.Node,
auth: ThreadStoreAuth,
) {
super(auth);
@@ -29,7 +29,7 @@ export abstract class YjsThreadStoreBase extends ThreadStore {
public getThreads(): Map {
const threadMap = new Map();
this.threadsYType.forEachAttr((yThread: any, id: string | number) => {
- if (yThread instanceof Y.Type) {
+ if (yThread instanceof Y.Node) {
threadMap.set(String(id), yTypeToThread(yThread));
}
});
diff --git a/packages/core/src/y/comments/yjsHelpers.ts b/packages/core/src/y/comments/yjsHelpers.ts
index 1ed4ff492f..c485143e8e 100644
--- a/packages/core/src/y/comments/yjsHelpers.ts
+++ b/packages/core/src/y/comments/yjsHelpers.ts
@@ -6,7 +6,7 @@ import type {
} from "../../comments/types.js";
export function commentToYType(comment: CommentData) {
- const yType = new Y.Type();
+ const yType = new Y.Node();
yType.setAttr("id", comment.id);
yType.setAttr("userId", comment.userId);
yType.setAttr("createdAt", comment.createdAt.getTime());
@@ -26,18 +26,18 @@ export function commentToYType(comment: CommentData) {
* this makes it easy to add / remove reactions and in a way that works local-first.
* The cost is that "reading" the reactions is a bit more complex (see yTypeToReactions).
*/
- yType.setAttr("reactionsByUser", new Y.Type());
+ yType.setAttr("reactionsByUser", new Y.Node());
yType.setAttr("metadata", comment.metadata);
return yType;
}
export function threadToYType(thread: ThreadData) {
- const yType = new Y.Type();
+ const yType = new Y.Node();
yType.setAttr("id", thread.id);
yType.setAttr("createdAt", thread.createdAt.getTime());
yType.setAttr("updatedAt", thread.updatedAt.getTime());
- const commentsType = new Y.Type();
+ const commentsType = new Y.Node();
commentsType.push(thread.comments.map((comment) => commentToYType(comment)));
@@ -55,7 +55,7 @@ type SingleUserCommentReactionData = {
userId: string;
};
-export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData {
+export function yTypeToReaction(yType: Y.Node): SingleUserCommentReactionData {
return {
emoji: yType.getAttr("emoji"),
createdAt: new Date(yType.getAttr("createdAt")),
@@ -63,8 +63,8 @@ export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData {
};
}
-function yTypeToReactions(yType: Y.Type): CommentReactionData[] {
- const flatReactions = [...yType.attrValues()].map((reaction: Y.Type) =>
+function yTypeToReactions(yType: Y.Node): CommentReactionData[] {
+ const flatReactions = [...yType.attrValues()].map((reaction: Y.Node) =>
yTypeToReaction(reaction),
);
// combine reactions by the same emoji
@@ -92,7 +92,7 @@ function yTypeToReactions(yType: Y.Type): CommentReactionData[] {
);
}
-export function yTypeToComment(yType: Y.Type): CommentData {
+export function yTypeToComment(yType: Y.Node): CommentData {
return {
type: "comment",
id: yType.getAttr("id"),
@@ -108,14 +108,14 @@ export function yTypeToComment(yType: Y.Type): CommentData {
};
}
-export function yTypeToThread(yType: Y.Type): ThreadData {
+export function yTypeToThread(yType: Y.Node): ThreadData {
return {
type: "thread",
id: yType.getAttr("id"),
createdAt: new Date(yType.getAttr("createdAt")),
updatedAt: new Date(yType.getAttr("updatedAt")),
- comments: ((yType.getAttr("comments") as Y.Type)?.toArray() || []).map(
- (comment) => yTypeToComment(comment as Y.Type),
+ comments: ((yType.getAttr("comments") as Y.Node)?.toArray() || []).map(
+ (comment) => yTypeToComment(comment as Y.Node),
),
resolved: yType.getAttr("resolved"),
resolvedUpdatedAt: new Date(yType.getAttr("resolvedUpdatedAt")),
diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts
index f752b48182..0a260a3c7b 100644
--- a/packages/core/src/y/extensions/AttributionExtension.test.ts
+++ b/packages/core/src/y/extensions/AttributionExtension.test.ts
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { User } from "../../user/index.js";
+import { cssVarUserId } from "../../user/index.js";
import { AttributionExtension } from "./AttributionExtension.js";
// Editors created during a test, destroyed in afterEach: an undestroyed
@@ -12,11 +13,12 @@ import { AttributionExtension } from "./AttributionExtension.js";
// the jsdom environment is torn down ("document is not defined" as an
// unhandled error - flaky, timing-dependent, mostly on slow CI).
const editors: BlockNoteEditor[] = [];
+const mounts: HTMLElement[] = [];
// A `resolveUsers` spy plus an editor with the AttributionExtension registered.
// No Yjs/collaboration needed — the extension's load plugin only cares that a
// transaction adds a `y-attributed-*` mark, which we do directly below.
-function createEditor() {
+function createEditor(user?: Partial) {
const resolveUsers = vi.fn(async (ids: string[]): Promise =>
ids.map((id) => ({
id,
@@ -24,18 +26,32 @@ function createEditor() {
avatarUrl: "",
color: "#123456",
colorLight: "#abcdef",
+ ...user,
})),
);
const editor = BlockNoteEditor.create({
extensions: [AttributionExtension({ resolveUsers })],
});
- editor.mount(document.createElement("div"));
+ const mount = document.createElement("div");
+ document.body.appendChild(mount);
+ mounts.push(mount);
+ editor.mount(mount);
editors.push(editor);
return { editor, resolveUsers };
}
+/** The `--user-color--{light,dark}` values on the editor root. */
+function rootColorVars(editor: BlockNoteEditor, userId: string) {
+ const root = editor.prosemirrorView!.dom as HTMLElement;
+ const key = cssVarUserId(userId);
+ return {
+ light: root.style.getPropertyValue(`--user-color-${key}-light`),
+ dark: root.style.getPropertyValue(`--user-color-${key}-dark`),
+ };
+}
+
// Add a `y-attributed-insert` mark carrying `userIds` over the first block's
// text, mirroring how the sync reconcile applies attribution marks.
function addInsertMark(editor: BlockNoteEditor, userIds: string[]) {
@@ -56,6 +72,9 @@ describe("AttributionExtension user loading", () => {
for (const editor of editors.splice(0)) {
editor._tiptapEditor.destroy();
}
+ for (const mount of mounts.splice(0)) {
+ mount.remove();
+ }
vi.restoreAllMocks();
});
@@ -71,6 +90,51 @@ describe("AttributionExtension user loading", () => {
expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything());
});
+ it("replaces attribution authors while allowing different attribution kinds to coexist", () => {
+ const { editor } = createEditor();
+ editor.replaceBlocks(editor.document, [{ content: "hello" }]);
+ const names = [
+ "y-attributed-insert",
+ "y-attributed-delete",
+ "y-attributed-format",
+ ];
+ for (const author of ["alice", "bob"]) {
+ editor.transact((tr) => {
+ tr.doc.descendants((node, pos) => {
+ if (node.isText) {
+ for (const name of names) {
+ tr.addMark(
+ pos,
+ pos + node.nodeSize,
+ editor.pmSchema.marks[name].create({
+ userIds: [author],
+ ...(name === "y-attributed-format"
+ ? { format: { bold: [author] } }
+ : {}),
+ }),
+ );
+ }
+ }
+ });
+ });
+ }
+ editor.prosemirrorState.doc.descendants((node) => {
+ if (node.isText) {
+ expect(node.marks).toHaveLength(3);
+ expect(node.marks.map((mark) => mark.type.name).sort()).toEqual(
+ [...names].sort(),
+ );
+ for (const mark of node.marks) {
+ expect(mark.attrs.userIds).toEqual(["bob"]);
+ }
+ expect(
+ node.marks.find((mark) => mark.type.name === "y-attributed-format")!
+ .attrs.format,
+ ).toEqual({ bold: ["bob"] });
+ }
+ });
+ });
+
it("does not load users for changes without attribution marks", () => {
const { editor, resolveUsers } = createEditor();
editor.replaceBlocks(editor.document, [{ content: "hello" }]);
@@ -92,4 +156,145 @@ describe("AttributionExtension user loading", () => {
// The user store dedupes already-cached ids, so `alice` is fetched once.
expect(resolveUsers).toHaveBeenCalledTimes(1);
});
+
+ it("writes both of a resolved author's colors to the editor root", async () => {
+ const { editor } = createEditor();
+ editor.replaceBlocks(editor.document, [{ content: "hello" }]);
+
+ addInsertMark(editor, ["alice"]);
+ await vi.waitFor(() =>
+ expect(rootColorVars(editor, "alice").dark).not.toBe(""),
+ );
+
+ expect(rootColorVars(editor, "alice")).toEqual({
+ light: "#abcdef",
+ dark: "#123456",
+ });
+ });
+
+ it("derives the light tint for an author that only has a `color`", async () => {
+ const { editor } = createEditor({ colorLight: undefined });
+ editor.replaceBlocks(editor.document, [{ content: "hello" }]);
+
+ addInsertMark(editor, ["alice"]);
+ await vi.waitFor(() =>
+ expect(rootColorVars(editor, "alice").dark).not.toBe(""),
+ );
+
+ expect(rootColorVars(editor, "alice")).toEqual({
+ light: "color-mix(in srgb, #123456 30%, white)",
+ dark: "#123456",
+ });
+ });
+
+ it("loads property authors, replaces stale attribution, and shows the changed keys", async () => {
+ const { editor, resolveUsers } = createEditor();
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "hello" },
+ ]);
+ const originalBlocks = editor.document;
+ const markType = editor.pmSchema.marks["y-attributed-attrs"];
+ function setChanges(
+ changes: Record,
+ ) {
+ editor.transact((tr) => tr.addNodeMark(2, markType.create({ changes })));
+ }
+ setChanges({ textAlignment: { userIds: ["alice"], timestamp: null } });
+ await vi.waitFor(() =>
+ expect(rootColorVars(editor, "alice").dark).toBe("#123456"),
+ );
+ expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything());
+ setChanges({ backgroundColor: { userIds: ["bob"], timestamp: null } });
+ await vi.waitFor(() =>
+ expect(rootColorVars(editor, "bob").dark).toBe("#123456"),
+ );
+ expect(editor.prosemirrorState.doc.nodeAt(2)!.marks).toHaveLength(1);
+ expect(editor.document).toEqual(originalBlocks);
+ const wrapper =
+ editor.prosemirrorView.dom.querySelector(
+ "[data-attributes]",
+ )!;
+ wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ expect(
+ editor.getExtension(AttributionExtension)!.store.state,
+ ).toMatchObject({
+ modificationType: "attrs",
+ attributes: ["backgroundColor"],
+ users: ["name-bob"],
+ contentType: "block",
+ provenance: "author",
+ });
+ });
+
+ it("keeps deletion styling directly on the node when attributes are also attributed", () => {
+ const { editor } = createEditor();
+ editor.replaceBlocks(editor.document, [{ content: "hello" }]);
+ editor.transact((tr) => {
+ tr.addNodeMark(
+ 2,
+ editor.pmSchema.marks["y-attributed-delete"].create({
+ userIds: ["alice"],
+ }),
+ );
+ tr.addNodeMark(
+ 2,
+ editor.pmSchema.marks["y-attributed-attrs"].create({
+ changes: { textAlignment: { userIds: ["alice"], timestamp: null } },
+ }),
+ );
+ });
+ expect(
+ editor.prosemirrorView.dom.querySelector(
+ "[data-attributes] > span > del > .bn-suggestion-node--delete > .bn-block-content",
+ ),
+ ).not.toBeNull();
+ });
+
+ it("only opens a tooltip in the editor containing the hovered mark", () => {
+ const { editor } = createEditor();
+ const { editor: otherEditor } = createEditor();
+ editor.replaceBlocks(editor.document, [{ content: "hello" }]);
+ const mark = editor.pmSchema.marks["y-attributed-attrs"].create({
+ changes: { textAlignment: { userIds: [], timestamp: null } },
+ });
+ editor.transact((tr) => tr.addNodeMark(2, mark));
+ editor.prosemirrorView.dom
+ .querySelector("[data-attributes]")!
+ .dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ expect(
+ editor.getExtension(AttributionExtension)!.store.state,
+ ).toBeDefined();
+ expect(
+ otherEditor.getExtension(AttributionExtension)!.store.state,
+ ).toBeUndefined();
+ otherEditor.prosemirrorView.dom.dispatchEvent(
+ new MouseEvent("mouseover", { bubbles: true }),
+ );
+ expect(
+ editor.getExtension(AttributionExtension)!.store.state,
+ ).toBeUndefined();
+ });
+
+ it("shows changed properties even when a version diff has no author", () => {
+ const { editor } = createEditor();
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "hello" },
+ ]);
+ const mark = editor.pmSchema.marks["y-attributed-attrs"].create({
+ changes: { textAlignment: { userIds: [], timestamp: null } },
+ });
+ editor.transact((tr) => tr.addNodeMark(2, mark));
+ const wrapper =
+ editor.prosemirrorView.dom.querySelector(
+ "[data-attributes]",
+ )!;
+ wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ expect(
+ editor.getExtension(AttributionExtension)!.store.state,
+ ).toMatchObject({
+ modificationType: "attrs",
+ attributes: ["textAlignment"],
+ users: [],
+ });
+ });
});
diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts
index 10e888830e..e296cf17c8 100644
--- a/packages/core/src/y/extensions/AttributionExtension.ts
+++ b/packages/core/src/y/extensions/AttributionExtension.ts
@@ -1,3 +1,4 @@
+import { AddNodeMarkStep } from "prosemirror-transform";
import { getChangedRanges } from "@tiptap/core";
import { Plugin, PluginKey, type Transaction } from "prosemirror-state";
import {
@@ -8,11 +9,13 @@ import {
import {
colorsForUserIds,
userColorVarNames,
+ userMarkColors,
normalizeToUserStore,
type UserStoreOrResolver,
} from "../../user/index.js";
import {
resolveAttributionMarkClassName,
+ getAttributionUserIds,
YAttributionMarksExtension,
type GetAttributionMarkClassName,
} from "./YAttributionMarks.js";
@@ -22,6 +25,7 @@ const ATTRIBUTION_MARK_TYPES = {
"y-attributed-insert": "insert",
"y-attributed-delete": "delete",
"y-attributed-format": "format",
+ "y-attributed-attrs": "attrs",
} as const;
const ATTRIBUTION_LOAD_PLUGIN_KEY = new PluginKey("attributionLoadUsers");
@@ -66,58 +70,33 @@ const parseFormatKeys = (formatJSON: string | undefined): string[] => {
return Object.keys(format);
};
-/**
- * The element with a real box to anchor the tooltip to. The wrapper is
- * `display: contents` (no box of its own), so use its content span child,
- * falling back further for block marks.
- */
-const getReferenceElement = (wrapper: Element): Element => {
- const content = wrapper.firstElementChild ?? wrapper;
- const rect = content.getBoundingClientRect();
- if (rect.width || rect.height) {
- return content;
- }
- return content.firstElementChild ?? content;
-};
-
-/**
- * The box the tooltip anchors to. The wrapper is `display: contents` (no box of
- * its own), so use its content span child, falling back further for block marks.
- * Exported for the React controller's floating-ui `getBoundingClientRect`.
- */
-export const getReferenceRect = (wrapper: Element): DOMRect =>
- getReferenceElement(wrapper).getBoundingClientRect();
-
-/**
- * The per-line client rects of the reference element, for floating-ui's
- * `inline()` middleware — it needs one rect per line to position off a
- * multi-line mark, and virtual elements don't get a default `getClientRects`.
- */
-export const getReferenceClientRects = (wrapper: Element): DOMRectList =>
- getReferenceElement(wrapper).getClientRects();
-
/**
* State for the currently-hovered suggestion mark's tooltip (`undefined` when
* none). The extension computes it; a React controller renders + positions it
* (see `AttributionTooltipController`).
*/
-export type AttributionTooltipState = {
+export type AttributionChange =
+ | {
+ modificationType: "insert" | "delete";
+ format?: never;
+ attributes?: never;
+ }
+ | { modificationType: "format"; format?: string[]; attributes?: never }
+ | { modificationType: "attrs"; attributes: string[]; format?: never };
+
+export type AttributionProvenance = "author" | "version";
+
+export type AttributionTooltipState = AttributionChange & {
/** The wrapper element the tooltip anchors to (floating-ui reference). */
anchor: HTMLElement;
/** Per-user background color, resolved from the user store (default path). */
color: string;
- /** The kind of change — `format` is the modification mark. */
- modificationType: "insert" | "delete" | "format";
/** Whether the mark wraps inline content or a whole block. */
contentType: "inline-content" | "block";
/** Resolved usernames (falls back to raw ids), for custom renderers. */
users: string[];
- /**
- * The changed format keys (e.g. `["bold", "italic"]`), present only for
- * `format` marks. This is the raw change context — the view layer turns it
- * into a localized label via its `formatChangeLabel`.
- */
- format?: string[];
+ /** Whether the labels identify document authors or a synthetic version. */
+ provenance: AttributionProvenance;
/**
* Class name from the `getAttributionMarkClassName` callback (override path).
* When present, the tooltip applies this and skips the inline `color`.
@@ -140,6 +119,8 @@ export const AttributionExtension = createExtension(
resolveUsers?: UserStoreOrResolver;
/** See {@link GetAttributionMarkClassName}. */
getAttributionMarkClassName?: GetAttributionMarkClassName;
+ /** Meaning of the identities carried by this extension's marks. */
+ provenance?: AttributionProvenance;
}
| undefined
>) => {
@@ -154,9 +135,6 @@ export const AttributionExtension = createExtension(
// over existing text and `tr.changedRange()` would miss.
const loadChangedUsers = (tr: Transaction) => {
const ranges = getChangedRanges(tr);
- if (ranges.length === 0) {
- return;
- }
// Most changes are local (often several steps in one small span), so scan a
// single range spanning all of them rather than each range individually.
let from = Infinity;
@@ -167,19 +145,31 @@ export const AttributionExtension = createExtension(
}
const ids = new Set();
- tr.doc.nodesBetween(from, to, (node) => {
- for (const mark of node.marks) {
- if (
- ATTRIBUTION_MARK_TYPES[
- mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES
- ]
- ) {
- const userIds = mark.attrs["userIds"] as string[] | null;
- userIds?.forEach((id) => ids.add(id));
- }
+ // AddNodeMarkStep has an empty position map, so getChangedRanges cannot
+ // locate its node. Load its authors directly from the added mark.
+ for (const step of tr.steps) {
+ if (
+ step instanceof AddNodeMarkStep &&
+ step.mark.type.name in ATTRIBUTION_MARK_TYPES
+ ) {
+ getAttributionUserIds(step.mark).forEach((id) => ids.add(id));
}
- return true;
- });
+ }
+ if (ranges.length > 0) {
+ tr.doc.nodesBetween(from, to, (node) => {
+ for (const mark of node.marks) {
+ if (
+ ATTRIBUTION_MARK_TYPES[
+ mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES
+ ]
+ ) {
+ const userIds = getAttributionUserIds(mark);
+ userIds?.forEach((id) => ids.add(id));
+ }
+ }
+ return true;
+ });
+ }
if (ids.size > 0) {
void userStore.loadUsers(Array.from(ids));
}
@@ -213,9 +203,10 @@ export const AttributionExtension = createExtension(
const syncRootVars = () => {
for (const [id, user] of userStore.store.state) {
const { light, dark } = userColorVarNames(id);
- if (user.color && user.colorLight) {
- dom.style.setProperty(light, user.colorLight);
- dom.style.setProperty(dark, user.color);
+ const colors = userMarkColors(user);
+ if (colors) {
+ dom.style.setProperty(light, colors.light);
+ dom.style.setProperty(dark, colors.dark);
} else {
dom.style.removeProperty(light);
dom.style.removeProperty(dark);
@@ -242,22 +233,31 @@ export const AttributionExtension = createExtension(
// and stays free of i18n/username resolution.
const attributionIdentity = (wrapper: HTMLElement) => {
const ids = parseUserIds(wrapper.dataset["userIds"]);
- if (ids.length === 0) {
+ if (ids.length === 0 && wrapper.dataset["attributes"] === undefined) {
return "";
}
const format = parseFormatKeys(wrapper.dataset["format"]);
- return `${format.join(",")}:${ids.join(",")}`;
+ return `${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`;
};
// Build the tooltip state from a wrapper's `data-*` attributes.
const buildState = (anchor: HTMLElement): AttributionTooltipState => {
- const isModification = anchor.dataset["format"] !== undefined;
- const modificationType: AttributionTooltipState["modificationType"] =
- isModification
- ? "format"
- : anchor.tagName === "INS"
- ? "insert"
- : "delete";
+ const change: AttributionChange =
+ anchor.dataset["attributes"] !== undefined
+ ? {
+ modificationType: "attrs",
+ attributes: parseFormatKeys(anchor.dataset["attributes"]),
+ }
+ : anchor.dataset["format"] !== undefined
+ ? {
+ modificationType: "format",
+ format: parseFormatKeys(anchor.dataset["format"]),
+ }
+ : {
+ modificationType:
+ anchor.tagName === "INS" ? "insert" : "delete",
+ };
+ const { modificationType } = change;
const contentType: AttributionTooltipState["contentType"] =
anchor.dataset["inline"] === "false" ? "block" : "inline-content";
@@ -269,12 +269,10 @@ export const AttributionExtension = createExtension(
userStore,
parseUserIds(anchor.dataset["userIds"]),
).dark,
- modificationType,
+ ...change,
contentType,
users: usersLabelArray(anchor.dataset["userIds"]),
- format: isModification
- ? parseFormatKeys(anchor.dataset["format"])
- : undefined,
+ provenance: options?.provenance ?? "author",
className: resolveAttributionMarkClassName(
getAttributionMarkClassName?.({ contentType, modificationType }),
"tooltip",
@@ -310,7 +308,10 @@ export const AttributionExtension = createExtension(
const onPointerOver = (event: Event) => {
const target = event.target instanceof Element ? event.target : null;
- const innermost = innermostAttributed(target);
+ const innermost =
+ target && dom.contains(target)
+ ? innermostAttributed(target)
+ : undefined;
if (!innermost) {
// Not over an attributed mark — drop the current tooltip.
hideTooltip();
diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts
index 968193b2bd..8e6712a405 100644
--- a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts
+++ b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { Block } from "../../blocks/defaultBlocks.js";
+import { en } from "../../i18n/locales/en.js";
+import { colorsForUserIds } from "../../user/index.js";
import { AttributionExtension } from "./AttributionExtension.js";
import { DiffVersioningExtension } from "./DiffVersioningExtension.js";
@@ -12,11 +14,17 @@ import { DiffVersioningExtension } from "./DiffVersioningExtension.js";
// Helpers
// ---------------------------------------------------------------------------
-function createDiffEditor() {
+const mounts: HTMLElement[] = [];
+
+function createDiffEditor(dictionary = en) {
const editor = BlockNoteEditor.create({
+ dictionary,
extensions: [DiffVersioningExtension()],
});
- editor.mount(document.createElement("div"));
+ const mount = document.createElement("div");
+ document.body.appendChild(mount);
+ mounts.push(mount);
+ editor.mount(mount);
return editor;
}
@@ -86,6 +94,9 @@ describe("DiffVersioningExtension", () => {
afterEach(() => {
editor.unmount();
+ for (const mount of mounts.splice(0)) {
+ mount.remove();
+ }
});
it("registers the y-attributed-* marks into the schema", () => {
@@ -158,13 +169,63 @@ describe("DiffVersioningExtension", () => {
// The version name is surfaced by resolving the marks' author id through the
// composed AttributionExtension's user store — this is what the hover tooltip
- // shows ("…by {name}").
+ // shows ("…in: {name}").
const attribution = editor.getExtension(AttributionExtension)!;
const authorId = "version:Draft 3";
await attribution.userStore.loadUsers([authorId]);
expect(attribution.userStore.getUser(authorId)?.username).toBe("Draft 3");
});
+ it("uses the localized fallback label and version provenance", async () => {
+ editor.unmount();
+ editor = createDiffEditor({
+ ...en,
+ versioning: {
+ ...en.versioning,
+ this_version: "Diese Version",
+ },
+ });
+ const baseline = blocksFromText("hello world");
+ const target = blocksFromText("hello new world");
+
+ editor.getExtension(DiffVersioningExtension)!.renderDiff(target, baseline);
+
+ const attribution = editor.getExtension(AttributionExtension)!;
+ const authorId = "version:Diese Version";
+ await attribution.userStore.loadUsers([authorId]);
+ expect(attribution.userStore.getUser(authorId)?.username).toBe(
+ "Diese Version",
+ );
+
+ editor.prosemirrorView.dom
+ .querySelector("ins[data-user-ids]")!
+ .dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ expect(attribution.store.state).toMatchObject({
+ modificationType: "insert",
+ users: ["Diese Version"],
+ provenance: "version",
+ });
+ });
+
+ it("colors the diff author with the palette's blue, tint included", async () => {
+ const baseline = blocksFromText("hello world");
+ const target = blocksFromText("hello brave new world");
+
+ const diff = editor.getExtension(DiffVersioningExtension)!;
+ diff.renderDiff(target, baseline, "Draft 3");
+
+ const attribution = editor.getExtension(AttributionExtension)!;
+ const authorId = "version:Draft 3";
+ await attribution.userStore.loadUsers([authorId]);
+
+ // Both halves are set, so the marks and their tooltip use the tuned pair
+ // rather than a tint derived from the saturated colour.
+ expect(colorsForUserIds(attribution.userStore, [authorId])).toEqual({
+ light: "#c9dcff",
+ dark: "#1e4fb0",
+ });
+ });
+
it("produces no attribution marks when the docs are identical", () => {
const same = blocksFromText("nothing changes here");
@@ -177,7 +238,7 @@ describe("DiffVersioningExtension", () => {
);
});
- it("clearDiff restores plain content with no attribution marks", () => {
+ it("replacing the rendered blocks drops the attribution marks", () => {
const baseline = blocksFromText("first version");
const target = blocksFromText("second version");
const restore = blocksFromText("live document");
@@ -186,7 +247,7 @@ describe("DiffVersioningExtension", () => {
diff.renderDiff(target, baseline);
expect(attributionMarkNames(editor).size).toBeGreaterThan(0);
- diff.clearDiff(restore);
+ editor.replaceBlocks(editor.document, restore);
expect(attributionMarkNames(editor).size).toBe(0);
expect(editor.prosemirrorState.doc.textContent).toBe("live document");
});
diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.ts b/packages/core/src/y/extensions/DiffVersioningExtension.ts
index 651a11a205..3173ea7efc 100644
--- a/packages/core/src/y/extensions/DiffVersioningExtension.ts
+++ b/packages/core/src/y/extensions/DiffVersioningExtension.ts
@@ -9,7 +9,7 @@ import {
_blocksToProsemirrorNode,
docDiffToDelta,
findTypeInOtherYdoc,
- getProseMirrorTrFromYFragment,
+ yNodeToTransaction,
} from "../utils.js";
import { AttributionExtension } from "./AttributionExtension.js";
import type { GetAttributionMarkClassName } from "./YAttributionMarks.js";
@@ -26,11 +26,9 @@ const DIFF_AUTHOR_ID_PREFIX = "version:";
/** The synthetic author id for a given version label. */
const diffAuthorId = (label: string) => DIFF_AUTHOR_ID_PREFIX + label;
-/** Fallback label used when a diff is rendered without a version name. */
-const DEFAULT_DIFF_LABEL = "This version";
-
-/** Color used for the version diff marks. */
-const DIFF_AUTHOR_COLOR = "#4363d8";
+/** Colors used for the version diff marks — the palette's blue. */
+const DIFF_AUTHOR_COLOR = "#1e4fb0";
+const DIFF_AUTHOR_COLOR_LIGHT = "#c9dcff";
export type DiffVersioningExtensionOptions = {
/**
@@ -46,13 +44,13 @@ export type DiffVersioningExtensionOptions = {
/**
* Records the author of each transaction on `doc` into a mutable
- * {@link Y.Attributions}, so the resulting attribution marks carry a non-empty
+ * {@link Y.ContentMap}, so the resulting attribution marks carry a non-empty
* `userIds` (and therefore resolve to a color/name). The listener must be
* attached *before* the attributed transaction runs. Mirrors the store used by
* the suggestion gallery example (`createAttributionStore`).
*/
-function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions {
- const attrs = new Y.Attributions();
+function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.ContentMap {
+ const attrs = Y.createContentMap();
doc.on("beforeObserverCalls", (tr) => {
if (!tr.insertSet.isEmpty()) {
Y.insertIntoIdMap(
@@ -83,7 +81,7 @@ function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions {
*
* It composes {@link AttributionExtension} (which registers the attribution
* marks and drives their colors + hover tooltips from a user store), and adds
- * the {@link renderDiff} / {@link clearDiff} capability.
+ * the {@link renderDiff} capability.
*
* Registering this extension is what makes non-collaborative versioning
* (`inMemoryVersioning`) capable of showing diffs: the in-memory preview
@@ -112,6 +110,10 @@ export const DiffVersioningExtension = createExtension(
editor: BlockNoteEditor;
}) => {
const color = options?.color ?? DIFF_AUTHOR_COLOR;
+ // Only the default pairs with a hand-tuned light tint; a caller-supplied
+ // colour gets the derived one (see `userMarkColors`).
+ const colorLight =
+ options?.color === undefined ? DIFF_AUTHOR_COLOR_LIGHT : undefined;
// Resolve a synthetic author id back to its version label. The id encodes
// the label (`version:`), so this is a pure decode — no shared mutable
@@ -125,92 +127,9 @@ export const DiffVersioningExtension = createExtension(
username: id.slice(DIFF_AUTHOR_ID_PREFIX.length),
avatarUrl: "",
color,
+ colorLight,
}));
- /**
- * Render a read-only diff of `baselineBlocks` → `snapshotBlocks` into the
- * editor. The changes are attributed to the version that introduced them:
- * pass `versionLabel` to label the diff marks (shown in their hover tooltip,
- * e.g. "Edited by: {versionLabel}"). Uses the "two-doc fork" recipe so the
- * two Y.Docs share history — a hard requirement for
- * `createDiffRenderer`, which diffs by Yjs client/clock ids.
- */
- const renderDiff = (
- snapshotBlocks: Block[],
- baselineBlocks: Block[],
- versionLabel: string = DEFAULT_DIFF_LABEL,
- ) => {
- const authorId = diffAuthorId(versionLabel);
-
- if (!editor.pmSchema.marks["y-attributed-insert"]) {
- throw new Error(
- "DiffVersioningExtension: the y-attributed-* marks are missing from " +
- "the schema. This should not happen — the extension registers them " +
- "via AttributionExtension.",
- );
- }
-
- const baselineNode = _blocksToProsemirrorNode(editor, baselineBlocks);
- const snapshotNode = _blocksToProsemirrorNode(editor, snapshotBlocks);
-
- // gc must stay off so the attribution manager can read the full struct
- // store (including deleted items) when diffing.
- const prevDoc = new Y.Doc({ gc: false });
- const prevType = prevDoc.get("prosemirror");
- prevDoc.transact(() => {
- prevType.applyDelta(docToDelta(baselineNode) as any);
- });
-
- // Fork prevDoc into nextDoc so they share client/clock ids, then apply the
- // baseline → snapshot delta as a new transaction. New content gets ids
- // that prevDoc lacks (→ inserts); items retained-away become deletes.
- const nextDoc = new Y.Doc({ gc: false });
- Y.applyUpdateV2(nextDoc, Y.encodeStateAsUpdateV2(prevDoc));
- const nextType = findTypeInOtherYdoc(prevType, nextDoc);
-
- // Attach the author store BEFORE applying the delta so the diff
- // transaction's inserts/deletes are attributed.
- const attrs = attributeTransactionsTo(nextDoc, authorId);
-
- const delta = docDiffToDelta(baselineNode, snapshotNode);
- nextDoc.transact(() => {
- nextType.applyDelta(delta as any);
- }, authorId);
-
- const renderer = Y.createDiffRenderer(prevDoc, nextDoc, { attrs });
-
- // Clear the live doc first so ProseMirror rebuilds node views from
- // scratch (BlockNote node views resolve their block eagerly via getPos()
- // and throw on a moved node). The diff then inserts the attributed content
- // against an empty doc.
- editor.replaceBlocks(editor.document, []);
-
- editor.exec((state, dispatch) => {
- const tr = getProseMirrorTrFromYFragment({
- tr: state.tr,
- fragment: nextType,
- renderer,
- });
- if (dispatch) {
- dispatch(tr);
- }
- return true;
- });
-
- prevDoc.destroy();
- nextDoc.destroy();
- };
-
- /**
- * Leave diff view: clear the (mark-carrying) document and restore the given
- * blocks. Clears first so stale node views for block-level marks are torn
- * down instead of reused.
- */
- const clearDiff = (restore: Block[]) => {
- editor.replaceBlocks(editor.document, []);
- editor.replaceBlocks(editor.document, restore);
- };
-
return {
key: "diffVersioning",
// Compose AttributionExtension: registers the y-attributed-* marks and
@@ -219,10 +138,78 @@ export const DiffVersioningExtension = createExtension(
AttributionExtension({
resolveUsers,
getAttributionMarkClassName: options?.getAttributionMarkClassName,
+ provenance: "version",
}),
],
- renderDiff,
- clearDiff,
+ /**
+ * Render a read-only diff of `baselineBlocks` → `snapshotBlocks` into the
+ * editor. The changes are attributed to the version that introduced them:
+ * pass `versionLabel` to label the diff marks (shown in their hover tooltip,
+ * e.g. "Inserted in: {versionLabel}"). Uses the "two-doc fork" recipe so the
+ * two Y.Docs share history — a hard requirement for
+ * `createDiffRenderer`, which diffs by Yjs client/clock ids.
+ */
+ renderDiff(
+ snapshotBlocks: Block[],
+ baselineBlocks: Block[],
+ versionLabel: string = editor.dictionary.versioning.this_version,
+ ) {
+ const authorId = diffAuthorId(versionLabel);
+
+ if (!editor.pmSchema.marks["y-attributed-insert"]) {
+ throw new Error(
+ "DiffVersioningExtension: the y-attributed-* marks are missing from " +
+ "the schema. This should not happen — the extension registers them " +
+ "via AttributionExtension.",
+ );
+ }
+
+ const baselineNode = _blocksToProsemirrorNode(editor, baselineBlocks);
+ const snapshotNode = _blocksToProsemirrorNode(editor, snapshotBlocks);
+
+ // gc must stay off so the attribution manager can read the full struct
+ // store (including deleted items) when diffing.
+ const prevDoc = new Y.Doc({ gc: false });
+ const prevType = prevDoc.get("prosemirror");
+ prevDoc.transact(() => {
+ prevType.applyDelta(docToDelta(baselineNode) as any);
+ });
+
+ // Fork prevDoc into nextDoc so they share client/clock ids, then apply the
+ // baseline → snapshot delta as a new transaction. New content gets ids
+ // that prevDoc lacks (→ inserts); items retained-away become deletes.
+ const nextDoc = new Y.Doc({ gc: false });
+ Y.applyUpdateV2(nextDoc, Y.encodeStateAsUpdateV2(prevDoc));
+ const nextType = findTypeInOtherYdoc(prevType, nextDoc);
+
+ // Attach the author store BEFORE applying the delta so the diff
+ // transaction's inserts/deletes are attributed.
+ const attrs = attributeTransactionsTo(nextDoc, authorId);
+
+ const delta = docDiffToDelta(baselineNode, snapshotNode);
+ nextDoc.transact(() => {
+ nextType.applyDelta(delta as any);
+ }, authorId);
+
+ const renderer = Y.createDiffRenderer(prevDoc, nextDoc, {
+ attributions: attrs,
+ });
+
+ // The diff is applied on top of whatever is currently on screen: the
+ // attributed content comes entirely from the baseline -> snapshot Y
+ // diff above, not from the ProseMirror before-state, so emptying the
+ // document first would only churn node views for no gain.
+ editor.exec((state, dispatch) => {
+ const tr = yNodeToTransaction(state.tr, nextType, { renderer });
+ if (dispatch) {
+ dispatch(tr);
+ }
+ return true;
+ });
+
+ prevDoc.destroy();
+ nextDoc.destroy();
+ },
};
},
);
diff --git a/packages/core/src/y/extensions/EmptyDocBinding.test.ts b/packages/core/src/y/extensions/EmptyDocBinding.test.ts
new file mode 100644
index 0000000000..7d72657f5d
--- /dev/null
+++ b/packages/core/src/y/extensions/EmptyDocBinding.test.ts
@@ -0,0 +1,282 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * Repro for the gallery's "insert image / add heading shows 2 blocks" bug.
+ *
+ * Mounting a collaborative editor on a clone of an EMPTY Y.Doc auto-creates a
+ * paragraph. That paragraph should carry the stable `initialBlockId` (the id
+ * `withCollaboration` seeds via `initialContent`), so every clone agrees on
+ * the same block identity. If each mount instead mints a RANDOM id, then
+ * `replaceBlocks([autoPara] → [newBlock])` + a CRDT merge keeps BOTH the
+ * phantom paragraph and the new block — and their order flips with the Yjs
+ * clientID tiebreak.
+ *
+ * Desired behavior is asserted below; failures demonstrate the bug.
+ *
+ * Also covers the multi-peer shape: several clients mounting on one shared,
+ * initially EMPTY Y.Doc must keep their skeletons out of Y until real content
+ * appears, so the first edit seeds the single shared root instead of racing
+ * competing roots — and late joiners render whatever is shared without
+ * leaving phantoms.
+ */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { blocksToYDoc, yDocToBlocks } from "../utils.js";
+import { withCollaboration } from "./index.js";
+
+const cleanups: Array<() => void> = [];
+afterEach(() => {
+ while (cleanups.length) {
+ cleanups.pop()!();
+ }
+});
+
+async function tick(times = 10) {
+ for (let i = 0; i < times; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+}
+
+// Headless editor, used only for its schema when seeding / reading Y.Docs.
+let schemaEditor: BlockNoteEditor | undefined;
+function getSchemaEditor() {
+ return (schemaEditor ??= BlockNoteEditor.create());
+}
+
+function cloneDoc(source: Y.Doc): Y.Doc {
+ const doc = new Y.Doc();
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(source));
+ cleanups.push(() => doc.destroy());
+ return doc;
+}
+
+function sortedIds(doc: Y.Doc): string[] {
+ return yDocToBlocks(getSchemaEditor(), doc, "doc")
+ .map((b) => b.id)
+ .sort();
+}
+
+function mountCollab(fragment: Y.Node): BlockNoteEditor {
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ provider: undefined,
+ user: { name: "Test User", color: "#FF0000" },
+ },
+ }),
+ );
+ const div = document.createElement("div");
+ document.body.appendChild(div);
+ editor.mount(div);
+ cleanups.push(() => {
+ editor.unmount();
+ div.remove();
+ });
+ return editor;
+}
+
+describe("empty-doc collaborative binding", () => {
+ it("seeds an empty Y.Doc from an empty block array", () => {
+ const seed = blocksToYDoc(getSchemaEditor(), [], "doc");
+ cleanups.push(() => seed.destroy());
+ expect(yDocToBlocks(getSchemaEditor(), seed, "doc")).toEqual([]);
+ });
+
+ it("mounting on an empty fragment yields the stable initialBlockId", async () => {
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ const editor = mountCollab(doc.get("doc"));
+ await tick();
+ expect(editor.document.map((b) => b.id)).toEqual(["initialBlockId"]);
+ });
+
+ it("two editors mounted on clones of one empty doc agree on the block id", async () => {
+ const seed = blocksToYDoc(getSchemaEditor(), [], "doc");
+ cleanups.push(() => seed.destroy());
+ const editorA = mountCollab(cloneDoc(seed).get("doc"));
+ const editorB = mountCollab(cloneDoc(seed).get("doc"));
+ await tick();
+ const idA = editorA.document.map((b) => b.id);
+ const idB = editorB.document.map((b) => b.id);
+ expect(idA).toEqual(["initialBlockId"]);
+ expect(idB).toEqual(idA);
+ });
+
+ it("replace-then-merge on an empty doc converges to the single new block", async () => {
+ const beforeDoc = blocksToYDoc(getSchemaEditor(), [], "doc");
+ cleanups.push(() => beforeDoc.destroy());
+ const afterDoc = cloneDoc(beforeDoc);
+ const userDoc = cloneDoc(beforeDoc);
+
+ const userEditor = mountCollab(userDoc.get("doc"));
+ await tick();
+ userEditor.replaceBlocks(userEditor.document, [
+ {
+ id: "h0",
+ type: "heading",
+ props: { level: 1 },
+ content: "New heading",
+ },
+ ]);
+ await tick();
+
+ Y.applyUpdate(afterDoc, Y.encodeStateAsUpdate(userDoc));
+ expect(
+ yDocToBlocks(getSchemaEditor(), afterDoc, "doc").map((b) => b.id),
+ ).toEqual(["h0"]);
+ });
+
+ it("deleting all blocks keeps the re-minted skeleton out of Y", async () => {
+ // Deleting every block makes BlockNote mint a fresh empty paragraph
+ // with a NEW random id. That skeleton is still initial content: it must
+ // not seed the empty fragment, and the next real edit must converge to
+ // just the new block. Regression for the e2e `addRemoveBlocks`
+ // "to empty doc" failures, where the re-minted skeleton was committed
+ // as a phantom paragraph (the id-sensitive pull divergence check
+ // treated it as real content).
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ const editor = mountCollab(doc.get("doc"));
+ await tick();
+ editor.replaceBlocks(editor.document, []);
+ await tick();
+ expect(doc.get("doc").length).toBe(0);
+
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "h0",
+ type: "heading",
+ props: { level: 1 },
+ content: "New heading",
+ },
+ ]);
+ await tick();
+ expect(sortedIds(doc)).toEqual(["h0"]);
+ expect(editor.document.map((b) => b.id)).toEqual(["h0"]);
+ });
+
+ it("mounting on an empty fragment does not write the skeleton to Y", async () => {
+ // The @y/prosemirror initial-content gate must engage: the local
+ // schema-default skeleton stays invisible to the sync layer until real
+ // content appears. If the mount commits its skeleton, every clone carries
+ // a distinct Y item and merges keep phantoms.
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ mountCollab(doc.get("doc"));
+ await tick();
+ expect(doc.get("doc").length).toBe(0);
+ });
+
+ it("gallery flow: a read-only editor mounted on the empty merge doc leaves no phantom", async () => {
+ // Mirrors VersionMerge: before/user/merge docs are clones of one empty
+ // doc, and an editor is mounted on EACH (the read-only Diff mounts while
+ // the merge doc is still empty). Forwarding the user's change into the
+ // merge doc must converge to the single new block — the Diff editor's own
+ // mount skeleton must not survive as an extra paragraph.
+ const beforeDoc = blocksToYDoc(getSchemaEditor(), [], "doc");
+ cleanups.push(() => beforeDoc.destroy());
+ const afterDoc = cloneDoc(beforeDoc);
+ const userDoc = cloneDoc(beforeDoc);
+
+ mountCollab(beforeDoc.get("doc"));
+ mountCollab(afterDoc.get("doc"));
+ const userEditor = mountCollab(userDoc.get("doc"));
+ await tick();
+ userEditor.replaceBlocks(userEditor.document, [
+ {
+ id: "h0",
+ type: "heading",
+ props: { level: 1 },
+ content: "New heading",
+ },
+ ]);
+ await tick();
+
+ Y.applyUpdate(afterDoc, Y.encodeStateAsUpdate(userDoc));
+ await tick();
+ expect(
+ yDocToBlocks(getSchemaEditor(), afterDoc, "doc").map((b) => b.id),
+ ).toEqual(["h0"]);
+ });
+});
+
+describe("multi-peer empty-doc initialization", () => {
+ // Deliberately NOT covered: fully independent docs that each receive a
+ // concurrent root insert and are merged after the fact. That merge yields
+ // two sibling `blockGroup`s under a `doc` whose content expression allows
+ // exactly one (`pm-nodes/Doc.ts`), which ProseMirror cannot represent in
+ // any implementation — `deltaToPNode` throws `failed to create node: doc`.
+ // That shape conflict predates the gate work (offline concurrent seeding)
+ // and is a different problem from the init race.
+ it("two peers on one shared empty doc converge through sequential edits", async () => {
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ const editorA = mountCollab(doc.get("doc"));
+ const editorB = mountCollab(doc.get("doc"));
+ await tick();
+
+ // Both skeletons stay local: the shared fragment is still empty.
+ expect(doc.get("doc").length).toBe(0);
+
+ // Peer's first edit seeds the single shared root …
+ editorA.replaceBlocks(editorA.document, [
+ { id: "block-a", type: "paragraph", content: "Alpha" },
+ ]);
+ await tick();
+
+ expect(sortedIds(doc)).toEqual(["block-a"]);
+ expect(editorB.document.map((b) => b.id)).toEqual(["block-a"]);
+
+ // … the other peer builds on it causally, no competing root appears.
+ editorB.replaceBlocks(editorB.document, [
+ { id: "block-a", type: "paragraph", content: "Alpha" },
+ { id: "block-b", type: "paragraph", content: "Beta" },
+ ]);
+ await tick();
+
+ expect(sortedIds(doc)).toEqual(["block-a", "block-b"]);
+ // Exactly one top-level blockGroup: no duplicate root content.
+ expect(doc.get("doc").length).toBe(1);
+ expect(JSON.stringify(editorA.document)).toBe(
+ JSON.stringify(editorB.document),
+ );
+ });
+
+ it("a late joiner mounting on the shared doc renders content with no phantom", async () => {
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ const editorA = mountCollab(doc.get("doc"));
+ await tick();
+
+ editorA.replaceBlocks(editorA.document, [
+ { id: "block-a", type: "paragraph", content: "Alpha" },
+ ]);
+ await tick();
+
+ // Late joiner mounts after content exists: the gate never arms
+ // (ytype is non-empty at bind) and the shared content renders as-is.
+ const editorB = mountCollab(doc.get("doc"));
+ await tick();
+
+ expect(editorB.document.map((b) => b.id)).toEqual(["block-a"]);
+ // Joining wrote nothing extra into the shared fragment.
+ expect(sortedIds(doc)).toEqual(["block-a"]);
+ expect(doc.get("doc").length).toBe(1);
+
+ // The joiner's edit flows back to the first peer, converging both views.
+ editorB.replaceBlocks(editorB.document, [
+ { id: "block-a", type: "paragraph", content: "Alpha" },
+ { id: "block-b", type: "paragraph", content: "Beta" },
+ ]);
+ await tick();
+
+ expect(sortedIds(doc)).toEqual(["block-a", "block-b"]);
+ expect(doc.get("doc").length).toBe(1);
+ expect(JSON.stringify(editorA.document)).toBe(
+ JSON.stringify(editorB.document),
+ );
+ });
+});
diff --git a/packages/core/src/y/extensions/ForkYDoc.ts b/packages/core/src/y/extensions/ForkYDoc.ts
index 683b0bb974..d17690623c 100644
--- a/packages/core/src/y/extensions/ForkYDoc.ts
+++ b/packages/core/src/y/extensions/ForkYDoc.ts
@@ -13,8 +13,8 @@ export const ForkYDocExtension = createExtension(
({ editor, options }: ExtensionOptions) => {
let forkedState:
| {
- originalFragment: Y.Type;
- forkedFragment: Y.Type;
+ originalFragment: Y.Node;
+ forkedFragment: Y.Node;
}
| undefined = undefined;
diff --git a/packages/core/src/y/extensions/RelativePositionMapping.test.ts b/packages/core/src/y/extensions/RelativePositionMapping.test.ts
index cd89448b76..a9e039831c 100644
--- a/packages/core/src/y/extensions/RelativePositionMapping.test.ts
+++ b/packages/core/src/y/extensions/RelativePositionMapping.test.ts
@@ -416,3 +416,38 @@ describe.skip("RelativePositionMapping (@y/y)", () => {
remoteEditor.unmount();
});
});
+
+it("tracks a block boundary through the view-based Yjs mapping API", async () => {
+ const doc = new Y.Doc();
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: doc.get("doc"),
+ user: { name: "Test", color: "#ff0000" },
+ provider: undefined,
+ },
+ }),
+ );
+ editor.mount(document.createElement("div"));
+ try {
+ // Binding initialization and repairs finish asynchronously.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ editor.replaceBlocks(editor.document, [
+ { id: "first", type: "paragraph", content: "first" },
+ { id: "second", type: "paragraph", content: "second" },
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const secondBlockPosition =
+ 1 + editor.prosemirrorState.doc.firstChild!.firstChild!.nodeSize;
+ const restore = trackPosition(editor, secondBlockPosition);
+ expect(restore()).toBe(secondBlockPosition);
+ editor.updateBlock("first", { content: "a longer first paragraph" });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(restore()).toBe(
+ 1 + editor.prosemirrorState.doc.firstChild!.firstChild!.nodeSize,
+ );
+ } finally {
+ editor.unmount();
+ doc.destroy();
+ }
+});
diff --git a/packages/core/src/y/extensions/RelativePositionMapping.ts b/packages/core/src/y/extensions/RelativePositionMapping.ts
index ce1ec0a9e6..728ad95bfa 100644
--- a/packages/core/src/y/extensions/RelativePositionMapping.ts
+++ b/packages/core/src/y/extensions/RelativePositionMapping.ts
@@ -19,29 +19,25 @@ export const RelativePositionMappingExtension = createExtension(
}
const posStore = relativePositionStore(
+ editor.prosemirrorView,
editor.prosemirrorState.doc.resolve(
position + (side === "right" ? 1 : -1),
),
- ySyncPluginState.ytype,
- ySyncPluginState.renderer,
);
+ if (posStore === null) {
+ throw new Error("Position not found, cannot track positions");
+ }
+
return () => {
- const curYSyncPluginState = ySyncPluginKey.getState(
- editor.prosemirrorState,
- ) as typeof ySyncPluginState;
- const pos = posStore(
- editor.prosemirrorState.doc,
- curYSyncPluginState.ytype,
- curYSyncPluginState.renderer,
- );
+ const pos = posStore(editor.prosemirrorView);
// This can happen if the element is garbage collected
if (pos === null) {
throw new Error("Position not found, cannot track positions");
}
- return pos + (side === "right" ? -1 : 1);
+ return pos.pos + (side === "right" ? -1 : 1);
};
},
} as const;
diff --git a/packages/core/src/y/extensions/Versioning.test.ts b/packages/core/src/y/extensions/Versioning.test.ts
index 88f3ad70c5..e3a3225741 100644
--- a/packages/core/src/y/extensions/Versioning.test.ts
+++ b/packages/core/src/y/extensions/Versioning.test.ts
@@ -18,15 +18,14 @@ import { createYjsVersioningAdapter } from "./Versioning.js";
* Simple in-memory Yjs versioning endpoints for tests.
* Stores snapshots and their binary content in plain Maps.
*/
-function createInMemoryYjsEndpoints(): VersioningEndpoints {
+function createInMemoryYjsEndpoints(): VersioningEndpoints {
const snapshots = new Map<
string,
{
id: string;
name?: string;
createdAt: number;
- updatedAt: number;
- restoredFromSnapshotId?: string;
+ restoredFrom?: { id: string; createdAt: number };
}
>();
const contents = new Map();
@@ -42,27 +41,29 @@ function createInMemoryYjsEndpoints(): VersioningEndpoints {
}
return {
- list: async () =>
- [...snapshots.values()].sort((a, b) => b.createdAt - a.createdAt),
+ list: async () => ({
+ // The live document is the current version; the endpoints only store the
+ // named ones.
+ current: { id: "current", createdAt: nextTimestamp() },
+ snapshots: [...snapshots.values()].sort(
+ (a, b) => b.createdAt - a.createdAt,
+ ),
+ }),
create: async (fragment, options) => {
const now = nextTimestamp();
const snapshot = {
id: crypto.randomUUID(),
- name: options?.name,
+ name: options.name,
createdAt: now,
- updatedAt: now,
- restoredFromSnapshotId: options?.restoredFromSnapshot?.id
- ? String(options.restoredFromSnapshot.id)
- : undefined,
};
contents.set(snapshot.id, Y.encodeStateAsUpdateV2(fragment.doc!));
snapshots.set(snapshot.id, snapshot);
return snapshot;
},
getContent: async (snapshot) => {
- const data = contents.get(String(snapshot.id));
+ const data = contents.get(snapshot.id);
if (!data) {
- throw new Error(`Snapshot ${String(snapshot.id)} not found`);
+ throw new Error(`Snapshot ${snapshot.id} not found`);
}
return data;
},
@@ -73,12 +74,11 @@ function createInMemoryYjsEndpoints(): VersioningEndpoints {
id: crypto.randomUUID(),
name: "Backup",
createdAt: backupTimestamp,
- updatedAt: backupTimestamp,
};
contents.set(backup.id, Y.encodeStateAsUpdateV2(fragment.doc!));
snapshots.set(backup.id, backup);
- const snapshotContent = contents.get(String(snapshot.id))!;
+ const snapshotContent = contents.get(snapshot.id)!;
const tempDoc = new Y.Doc();
Y.applyUpdateV2(tempDoc, snapshotContent);
@@ -87,8 +87,7 @@ function createInMemoryYjsEndpoints(): VersioningEndpoints {
id: crypto.randomUUID(),
name: "Restored Snapshot",
createdAt: restoredTimestamp,
- updatedAt: restoredTimestamp,
- restoredFromSnapshotId: String(snapshot.id),
+ restoredFrom: { id: snapshot.id, createdAt: snapshot.createdAt },
};
contents.set(restored.id, Y.encodeStateAsUpdateV2(tempDoc));
snapshots.set(restored.id, restored);
@@ -97,12 +96,11 @@ function createInMemoryYjsEndpoints(): VersioningEndpoints {
return snapshotContent;
},
rename: async (snapshot, name) => {
- const s = snapshots.get(String(snapshot.id));
+ const s = snapshots.get(snapshot.id);
if (!s) {
- throw new Error(`Snapshot ${String(snapshot.id)} not found`);
+ throw new Error(`Snapshot ${snapshot.id} not found`);
}
s.name = name;
- s.updatedAt = nextTimestamp();
},
};
}
@@ -260,7 +258,7 @@ describe("createYjsVersioningAdapter", () => {
const adapter = createYjsVersioningAdapter(ctx.editor, ctx.fragment);
// Should not throw and should leave the live document untouched.
- expect(() => adapter.preview.applyRestore(new Uint8Array())).not.toThrow();
+ expect(() => adapter.preview.applyRestore!(new Uint8Array())).not.toThrow();
expect(getEditorText(ctx.editor)).toContain("Content");
});
});
@@ -293,7 +291,11 @@ describe("Yjs versioning integration (VersioningExtension + in-memory endpoints)
await versioning.previewSnapshot(snapshot.id);
- expect(versioning.store.state.previewedSnapshotId).toBe(snapshot.id);
+ expect(versioning.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: snapshot.id,
+ compareToId: undefined,
+ });
expect(getEditorText(ctx.editor)).toContain("Snapshot content");
expect(getEditorText(ctx.editor)).not.toContain("Current");
});
@@ -338,8 +340,8 @@ describe("Yjs versioning integration (VersioningExtension + in-memory endpoints)
// List and verify ordering
const list = await versioning.list();
- expect(list).toHaveLength(2);
- expect(list[0]!.id).toBe(v2.id);
+ expect(list.snapshots).toHaveLength(2);
+ expect(list.snapshots[0]!.id).toBe(v2.id);
// Browse previews
await versioning.previewSnapshot(v1.id);
@@ -393,16 +395,39 @@ describe("Yjs versioning integration (VersioningExtension + in-memory endpoints)
{ type: "paragraph", content: "Current live" },
]);
+ await versioning.list();
+
// Preview older, then newer
await versioning.previewSnapshot(v1.id);
expect(getEditorText(ctx.editor)).toContain("Version 1");
await versioning.previewSnapshot(v2.id);
expect(getEditorText(ctx.editor)).toContain("Version 2");
- expect(versioning.store.state.previewedSnapshotId).toBe(v2.id);
+ expect(versioning.store.state.view).toEqual({
+ mode: "snapshot",
+ snapshotId: v2.id,
+ compareToId: undefined,
+ });
// Exit back to live
versioning.exitPreview();
expect(getEditorText(ctx.editor)).toContain("Current live");
});
+
+ it("locks the editor while previewing and unlocks on exit", async () => {
+ ctx = createCollabEditor();
+ const versioning = ctx.editor.getExtension(VersioningExtension)!;
+
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Saved state" },
+ ]);
+ const snapshot = await versioning.create!({ name: "v1" });
+ expect(ctx.editor.isEditable).toBe(true);
+
+ await versioning.previewSnapshot(snapshot.id);
+ expect(ctx.editor.isEditable).toBe(false);
+
+ versioning.exitPreview();
+ expect(ctx.editor.isEditable).toBe(true);
+ });
});
diff --git a/packages/core/src/y/extensions/Versioning.ts b/packages/core/src/y/extensions/Versioning.ts
index bda7009057..9e4a1e9950 100644
--- a/packages/core/src/y/extensions/Versioning.ts
+++ b/packages/core/src/y/extensions/Versioning.ts
@@ -1,115 +1,51 @@
-import { configureYProsemirror, pauseSync } from "@y/prosemirror";
+import { configureYProsemirror } from "@y/prosemirror";
import * as Y from "@y/y";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { PreviewController } from "../../extensions/Versioning/index.js";
-import {
- findTypeInOtherYdoc,
- getProseMirrorTrFromYFragment,
-} from "../utils.js";
+import { serializeFragment } from "./snapshotCodec.js";
+import { showSnapshotPreview } from "./snapshotPreview.js";
/**
- * Empties the document before a {@link configureYProsemirror} refill so
- * ProseMirror rebuilds every node view instead of reusing a stale-positioned one
- * (BlockNote node views resolve their block eagerly via `getPos()` and throw on
- * a moved node). Sync is paused first so the clear never reaches the Y.Doc.
- *
- * TODO: remove once `configureYProsemirror` applies a minimal diff.
+ * No-op: the server applies the restore and publishes a reverting update that
+ * propagates over live sync; exitPreview already ran before this.
*/
-function clearDocumentForConfigure(editor: BlockNoteEditor) {
- // Pause sync (ytype -> null) so the deletion below stays local.
- editor.exec(pauseSync);
- editor.removeBlocks(editor.document);
-}
+export function applyServerSideRestore(_snapshotContent: Uint8Array): void {}
-/**
- * Creates a Yjs-specific adapter that provides the {@link PreviewController}
- * and `getCurrentDocument` callback required by the base
- * {@link VersioningExtension}.
- *
- * This is wired automatically by the {@link CollaborationExtension} when
- * `versioningEndpoints` is provided. You only need to call this directly if
- * you're using the `VersioningExtension` outside of the collaboration wrapper.
- */
+/** Wire a Yjs fragment into the versioning adapter's preview/serialize hooks. */
export function createYjsVersioningAdapter(
editor: BlockNoteEditor,
- fragment: Y.Type,
+ fragment: Y.Node,
): {
preview: PreviewController;
- getCurrentDocument: () => Y.Type;
+ getCurrentDocument: () => Y.Node;
serializeCurrentContent: () => Uint8Array;
} {
return {
- getCurrentDocument: () => fragment,
- // Serialise the live document as a V2 update — the same format that
- // `getContent` returns (via `convertUpdateFormatV1ToV2`) and that
- // `enterPreview` consumes (`applyUpdateV2`). Used to render a read-only
- // diff of the live document against a snapshot.
- serializeCurrentContent: () => Y.encodeStateAsUpdateV2(fragment.doc!),
+ getCurrentDocument() {
+ return fragment;
+ },
+ serializeCurrentContent() {
+ return serializeFragment(fragment);
+ },
preview: {
- enterPreview: (
+ enterPreview(
snapshotContent: Uint8Array,
compareToContent?: Uint8Array,
attributions?: Y.ContentMap,
- ) => {
- let prevSnapshot: { fragment: Y.Type } | undefined;
- if (compareToContent) {
- const compareToDoc = new Y.Doc({ isSuggestionDoc: true });
- Y.applyUpdateV2(compareToDoc, compareToContent);
- prevSnapshot = {
- fragment: findTypeInOtherYdoc(fragment, compareToDoc),
- };
- }
-
- const doc = new Y.Doc();
- Y.applyUpdateV2(doc, snapshotContent);
- // Empty the document before reconfiguring so ProseMirror rebuilds node
- // views from scratch instead of reusing stale-positioned ones. See
- // clearDocumentForConfigure.
- clearDocumentForConfigure(editor);
-
- editor.exec((state, dispatch) => {
- const tr = getProseMirrorTrFromYFragment({
- tr: state.tr,
- fragment: findTypeInOtherYdoc(fragment, doc),
- // Pass the optional content map as `attrs` so the diff renderer
- // knows who/when authored each change. Without it, the renderer
- // only produces "what changed" (empty userIds, null timestamps) and
- // downstream mark tooltips show "unknown / unknown time".
- renderer: prevSnapshot
- ? Y.createDiffRenderer(
- prevSnapshot.fragment.doc!,
- doc,
- attributions ? { attrs: attributions } : undefined,
- )
- : undefined,
- });
- if (dispatch) {
- dispatch(tr);
- }
- return true;
- });
+ ) {
+ showSnapshotPreview(
+ editor,
+ fragment,
+ snapshotContent,
+ compareToContent,
+ attributions,
+ );
},
- exitPreview: () => {
- // Empty the document before reconfiguring so ProseMirror rebuilds node
- // views from scratch instead of reusing stale-positioned ones. See
- // clearDocumentForConfigure.
- clearDocumentForConfigure(editor);
+ exitPreview() {
editor.exec(configureYProsemirror({ ytype: fragment }));
},
- applyRestore: (_snapshotContent: Uint8Array) => {
- // For Yjs-backed versioning, restoration happens on the server (e.g.
- // YHub's `/rollback` endpoint) which publishes a reverting update to
- // the document's room. That update propagates back to this client over
- // the live sync connection and updates `fragment` automatically, so
- // there is nothing to apply locally — we only need to leave preview
- // mode. `exitPreview` is already called by the base extension before
- // this runs, so this is a no-op.
- //
- // Note: this assumes `endpoints.restore` performs the server-side
- // restore. The default in-memory adapter has no server, which is why
- // this is specific to the Yjs collaboration setup.
- },
+ applyRestore: applyServerSideRestore,
},
};
}
diff --git a/packages/core/src/y/extensions/YAttributionMarks.ts b/packages/core/src/y/extensions/YAttributionMarks.ts
index 71baf0849e..88f5de9d36 100644
--- a/packages/core/src/y/extensions/YAttributionMarks.ts
+++ b/packages/core/src/y/extensions/YAttributionMarks.ts
@@ -21,7 +21,7 @@ import {
*/
export type AttributionMarkStyleInfo = {
contentType: "inline-content" | "block";
- modificationType: "insert" | "delete" | "format";
+ modificationType: "insert" | "delete" | "format" | "attrs";
};
/**
@@ -63,6 +63,28 @@ export const resolveAttributionMarkClassName = (
? result
: result[target];
+/** The upstream attribute mark stores authors separately for each changed property. */
+export type AttributeChanges = Record<
+ string,
+ { userIds: string[]; timestamp: number | null }
+>;
+
+export function getAttributeChanges(mark: PMMark): AttributeChanges {
+ return mark.attrs["changes"] ?? {};
+}
+
+export function getAttributionUserIds(mark: PMMark): string[] {
+ return mark.type.name === "y-attributed-attrs"
+ ? [
+ ...new Set(
+ Object.values(getAttributeChanges(mark)).flatMap(
+ (change) => change.userIds,
+ ),
+ ),
+ ]
+ : (mark.attrs["userIds"] ?? []);
+}
+
/**
* Shared mark view for the attribution marks (insert / delete / modification).
* It renders the marked content and tags the wrapper with the author(s) via
@@ -82,7 +104,7 @@ export const resolveAttributionMarkClassName = (
*/
const createAttributionMarkView =
(
- type: "insert" | "delete" | "modification",
+ type: "insert" | "delete" | "modification" | "attrs",
options?: {
editor?: BlockNoteEditor;
getAttributionMarkClassName?: GetAttributionMarkClassName;
@@ -104,9 +126,13 @@ const createAttributionMarkView =
const dom = document.createElement(tag);
Object.assign(dom.dataset, {
- userIds: JSON.stringify(mark.attrs["userIds"]),
+ userIds: JSON.stringify(getAttributionUserIds(mark)),
inline: String(inline),
});
+ if (type === "attrs") {
+ dom.dataset["type"] = "attributes";
+ dom.dataset["attributes"] = JSON.stringify(getAttributeChanges(mark));
+ }
if (type === "modification") {
dom.dataset["type"] = "modification";
dom.dataset["format"] = JSON.stringify(mark.attrs["format"]);
@@ -137,7 +163,7 @@ const createAttributionMarkView =
// fallback, so a mark is colored before the user resolves and recolors via
// the cascade afterward. When an override class owns the styling, no per-user
// color is applied at all.
- const userIds = (mark.attrs["userIds"] as string[] | null) ?? [];
+ const userIds = getAttributionUserIds(mark);
const firstId = userIds[0];
const fallback = firstId
? fallbackColorForUserId(firstId)
@@ -208,7 +234,8 @@ export const YAttributedInsertion = Mark.create<{
}>({
name: "y-attributed-insert",
inclusive: false,
- excludes: "",
+ // Keep default self-exclusion: an updated author list replaces this mark,
+ // while insertion, deletion, and formatting marks can still coexist.
// Two groups: `BLOCK_LEVEL_SUGGESTION_GROUP` lets the mark sit on block nodes
// (see `suggestionMarks`), so a whole block can be marked as inserted in
// suggestion mode; `NON_FORMATTING_MARK_GROUP` lets it annotate text inside
@@ -240,7 +267,6 @@ export const YAttributedDeletion = Mark.create<{
}>({
name: "y-attributed-delete",
inclusive: false,
- excludes: "",
group: `${BLOCK_LEVEL_SUGGESTION_GROUP} ${NON_FORMATTING_MARK_GROUP}`,
addAttributes() {
return {
@@ -268,7 +294,6 @@ export const YAttributedFormat = Mark.create<{
}>({
name: "y-attributed-format",
inclusive: false,
- excludes: "",
group: `${BLOCK_LEVEL_SUGGESTION_GROUP} ${NON_FORMATTING_MARK_GROUP}`,
addAttributes() {
return {
@@ -291,8 +316,34 @@ export const YAttributedFormat = Mark.create<{
},
});
+export const YAttributedAttributes = Mark.create<{
+ getAttributionMarkClassName?: GetAttributionMarkClassName;
+}>({
+ name: "y-attributed-attrs",
+ // Wrap insertion/deletion marks, so their content spans still directly wrap
+ // the node that paints the highlight or deletion badge (especially tables).
+ priority: 110,
+ inclusive: false,
+ // Keep ProseMirror's default self-exclusion: each update replaces the
+ // per-property map instead of stacking stale copies of the mark.
+ group: `${BLOCK_LEVEL_SUGGESTION_GROUP} ${NON_FORMATTING_MARK_GROUP}`,
+ addAttributes() {
+ return { changes: { default: null } };
+ },
+ addMarkView() {
+ return createAttributionMarkView("attrs", {
+ getAttributionMarkClassName: this.options.getAttributionMarkClassName,
+ });
+ },
+ extendMarkSchema(extension) {
+ return extension.name === this.name
+ ? ({ blocknoteIgnore: true } satisfies MarkSpec)
+ : {};
+ },
+});
+
/**
- * Bundles the three `y-attributed-*` suggestion marks into a single BlockNote
+ * Bundles the four `y-attributed-*` suggestion marks into a single BlockNote
* extension, so they can be registered wherever they're actually needed (the
* Yjs collaboration extension, or a test that exercises suggestions) instead of
* living in the default schema. The marks opt into being allowed on block nodes
@@ -312,6 +363,9 @@ export const YAttributionMarksExtension = createExtension(
YAttributedDeletion.configure({
getAttributionMarkClassName: options?.getAttributionMarkClassName,
}),
+ YAttributedAttributes.configure({
+ getAttributionMarkClassName: options?.getAttributionMarkClassName,
+ }),
YAttributedFormat.configure({
getAttributionMarkClassName: options?.getAttributionMarkClassName,
}),
diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts
index aff9f3ab21..df91c06bd3 100644
--- a/packages/core/src/y/extensions/YSync.ts
+++ b/packages/core/src/y/extensions/YSync.ts
@@ -1,9 +1,20 @@
-import { configureYProsemirror, syncPlugin } from "@y/prosemirror";
+import {
+ configureYProsemirror,
+ syncPlugin,
+ ySyncPluginKey,
+} from "@y/prosemirror";
+import type { Node } from "prosemirror-model";
import {
type ExtensionOptions,
createExtension,
} from "../../editor/BlockNoteExtension.js";
import { blockMatchNodes } from "./blockMatchNodes.js";
+import { docToBlocks } from "../../api/nodeConversions/nodeToBlock.js";
+import type {
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../schema/index.js";
import { CollaborationOptions } from "./index.js";
/**
@@ -22,6 +33,31 @@ import { CollaborationOptions } from "./index.js";
* `AttributionExtension` applies colors as a decoration layer that can
* update independently of the mark representation.
*/
+/**
+ * Whether a ProseMirror document is BlockNote's initial (empty) state:
+ * a single empty paragraph block. Ids and props are deliberately ignored —
+ * a freshly mounted editor mints a random block id, but that skeleton still
+ * carries no real content and must not be written into an empty Y fragment.
+ * Anything more (extra blocks, non-empty text, a non-paragraph block, nested
+ * children) counts as real content and syncs immediately.
+ */
+function isInitialBlockNoteDoc<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(doc: Node): boolean {
+ const blocks = docToBlocks(doc);
+ const block = blocks.length === 1 ? blocks[0] : undefined;
+ if (!block || block.type !== "paragraph" || block.children.length !== 0) {
+ return false;
+ }
+ const { content } = block;
+ return (
+ content === undefined ||
+ ((typeof content === "string" || Array.isArray(content)) &&
+ content.length === 0)
+ );
+}
export const mapAttributionToMark = (
format: Record | null,
attribution: {
@@ -69,6 +105,13 @@ export const YSyncExtension = createExtension(
key: "ySync",
fragment: options.fragment,
mount: () => {
+ // 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 (ySyncPluginKey.getState(editor.prosemirrorState)?.ytype) {
+ return;
+ }
+
const configure = () => {
editor.exec(
configureYProsemirror({
@@ -117,6 +160,10 @@ export const YSyncExtension = createExtension(
// needed; `blockContainer` already whitelists the `y-attributed-*`
// marks. See blockMatchNodes.ts.
customCompare: blockMatchNodes,
+ // Initial-empty gate: a single empty paragraph (any id/props) must
+ // not seed an empty Y fragment — see isInitialBlockNoteDoc above
+ // and "Initial-content gate" in ProsemirrorRdt's doc.
+ isInitialContent: isInitialBlockNoteDoc,
}),
],
runsBefore: ["default"],
diff --git a/packages/core/src/y/extensions/YSyncRemount.test.ts b/packages/core/src/y/extensions/YSyncRemount.test.ts
new file mode 100644
index 0000000000..e561998d33
--- /dev/null
+++ b/packages/core/src/y/extensions/YSyncRemount.test.ts
@@ -0,0 +1,99 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import { ySyncPluginKey } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { SuggestionsExtension, withCollaboration } from "./index.js";
+
+const cleanups: Array<() => void> = [];
+afterEach(() => {
+ while (cleanups.length) {
+ cleanups.pop()!();
+ }
+});
+
+function mountEditor(
+ fragment: Y.Node,
+ suggestionDoc?: Y.Doc,
+ renderer?: Y.DiffRenderer,
+) {
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ suggestionDoc,
+ renderer,
+ provider: undefined,
+ user: { name: "Test User", color: "#FF0000" },
+ },
+ }),
+ );
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ editor.mount(element);
+ cleanups.push(() => {
+ editor.unmount();
+ element.remove();
+ });
+ return { editor, element };
+}
+
+async function tick() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe("collaboration editor remount", () => {
+ it("keeps syncing local and remote edits after remounting the same editor", async () => {
+ const doc = new Y.Doc();
+ cleanups.push(() => doc.destroy());
+ const { editor: alice, element } = mountEditor(doc.get("doc"));
+ const { editor: bob } = mountEditor(doc.get("doc"));
+
+ alice.unmount();
+ alice.mount(element);
+ alice.replaceBlocks(alice.document, [
+ { id: "alice-block", type: "paragraph", content: "Alice" },
+ ]);
+ await tick();
+ expect(bob.document[0].content).toEqual(alice.document[0].content);
+ expect(doc.get("doc").length).toBe(1);
+
+ bob.replaceBlocks(bob.document, [
+ { id: "bob-block", type: "paragraph", content: "Bob" },
+ ]);
+ await tick();
+ expect(alice.document[0].content).toEqual(bob.document[0].content);
+ });
+
+ it("preserves the active suggestion document and renderer on remount", async () => {
+ const doc = new Y.Doc();
+ const suggestionDoc = new Y.Doc({ isSuggestionDoc: true });
+ cleanups.push(
+ () => doc.destroy(),
+ () => suggestionDoc.destroy(),
+ );
+ const renderer = Y.createDiffRenderer(doc, suggestionDoc);
+ renderer.suggestionMode = true;
+ const { editor, element } = mountEditor(
+ doc.get("doc"),
+ suggestionDoc,
+ renderer,
+ );
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+ const activeType = ySyncPluginKey.getState(editor.prosemirrorState)!.ytype;
+
+ editor.unmount();
+ editor.mount(element);
+ const state = ySyncPluginKey.getState(editor.prosemirrorState)!;
+ expect(state.ytype).toBe(activeType);
+ expect(state.renderer).toBe(renderer);
+
+ editor.replaceBlocks(editor.document, [
+ { id: "suggestion-block", type: "paragraph", content: "Suggested" },
+ ]);
+ await tick();
+ expect(suggestionDoc.get("doc").length).toBe(1);
+ expect(doc.get("doc").length).toBe(0);
+ });
+});
diff --git a/packages/core/src/y/extensions/index.ts b/packages/core/src/y/extensions/index.ts
index c93f87cc31..9b4a5825f0 100644
--- a/packages/core/src/y/extensions/index.ts
+++ b/packages/core/src/y/extensions/index.ts
@@ -23,7 +23,7 @@ export type CollaborationOptions = {
/**
* The Yjs Type that's used for collaboration.
*/
- fragment: Y.Type;
+ fragment: Y.Node;
/**
* The user info for the current user that's shown to other collaborators.
*/
@@ -76,8 +76,16 @@ export type CollaborationOptions = {
* The endpoints for the versioning functionality.
*/
versioningEndpoints?:
- | VersioningEndpoints
- | VersioningEndpointsFactory;
+ | VersioningEndpoints
+ | VersioningEndpointsFactory;
+
+ /**
+ * Whether entering a version preview scrolls the first change of the diff
+ * into view. Forwarded to the {@link VersioningExtension}.
+ *
+ * @default true
+ */
+ scrollToFirstChange?: boolean;
};
export const CollaborationExtension = createExtension(
@@ -105,6 +113,7 @@ export const CollaborationExtension = createExtension(
...createYjsVersioningAdapter(editor, options.fragment),
endpoints: options.versioningEndpoints,
resolveUsers: userStore,
+ scrollToFirstChange: options.scrollToFirstChange,
})
: null,
AttributionExtension({
diff --git a/packages/core/src/y/extensions/snapshotCodec.test.ts b/packages/core/src/y/extensions/snapshotCodec.test.ts
new file mode 100644
index 0000000000..e48a874474
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotCodec.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, expect, it, vi } from "vite-plus/test";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { withCollaboration } from "./index.js";
+import {
+ decodeFragmentUpdate,
+ destroyDecodedFragment,
+ serializeFragment,
+} from "./snapshotCodec.js";
+
+function createSeededContext() {
+ const doc = new Y.Doc();
+ const fragment = doc.get("doc");
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ user: { name: "Codec Test", color: "#00ff00" },
+ provider: undefined,
+ },
+ }),
+ );
+ editor.mount(document.createElement("div"));
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "codec roundtrip" },
+ ]);
+ return { editor, doc, fragment };
+}
+
+describe("ySnapshotCodec", () => {
+ it("round-trips the live fragment through serialize/decode", () => {
+ const ctx = createSeededContext();
+ try {
+ const update = serializeFragment(ctx.fragment);
+ expect(update).toBeInstanceOf(Uint8Array);
+ expect(update.length).toBeGreaterThan(0);
+
+ const decoded = decodeFragmentUpdate(ctx.fragment, update);
+ try {
+ expect(decoded.doc).toBeInstanceOf(Y.Doc);
+ expect(decoded.fragment).toBeDefined();
+ } finally {
+ destroyDecodedFragment(decoded);
+ }
+ } finally {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("decodes the baseline with the suggestion-doc flag without throwing", () => {
+ const ctx = createSeededContext();
+ try {
+ const update = serializeFragment(ctx.fragment);
+ const baseline = decodeFragmentUpdate(ctx.fragment, update, {
+ suggestionDoc: true,
+ });
+ try {
+ expect(baseline.fragment).toBeDefined();
+ } finally {
+ destroyDecodedFragment(baseline);
+ }
+ } finally {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("destroys the throwaway doc when decoding fails", () => {
+ const ctx = createSeededContext();
+ const destroySpy = vi.spyOn(Y.Doc.prototype, "destroy");
+ try {
+ // Random bytes are not a valid V2 update, so decoding must throw — and
+ // the throwaway doc created inside must already be released.
+ expect(() =>
+ decodeFragmentUpdate(ctx.fragment, new Uint8Array([255, 255, 255])),
+ ).toThrow();
+ expect(destroySpy).toHaveBeenCalledTimes(1);
+ } finally {
+ destroySpy.mockRestore();
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("tolerates destroying undefined and double-destroy", () => {
+ const ctx = createSeededContext();
+ try {
+ expect(() => destroyDecodedFragment(undefined)).not.toThrow();
+ const decoded = decodeFragmentUpdate(
+ ctx.fragment,
+ serializeFragment(ctx.fragment),
+ );
+ destroyDecodedFragment(decoded);
+ expect(() => destroyDecodedFragment(decoded)).not.toThrow();
+ } finally {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+});
diff --git a/packages/core/src/y/extensions/snapshotCodec.ts b/packages/core/src/y/extensions/snapshotCodec.ts
new file mode 100644
index 0000000000..985c68502f
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotCodec.ts
@@ -0,0 +1,39 @@
+import * as Y from "@y/y";
+
+import { findTypeInOtherYdoc } from "../utils.js";
+
+/** Serialize the live fragment as a V2 update. */
+export function serializeFragment(fragment: Y.Node): Uint8Array {
+ return Y.encodeStateAsUpdateV2(fragment.doc!);
+}
+
+export type DecodedFragment = {
+ doc: Y.Doc;
+ fragment: Y.Node;
+};
+
+/** Materialize a stored update into a throwaway doc and resolve `fragment` inside it. */
+export function decodeFragmentUpdate(
+ fragment: Y.Node,
+ content: Uint8Array,
+ opts?: { suggestionDoc?: boolean },
+): DecodedFragment {
+ const doc = new Y.Doc(
+ opts?.suggestionDoc ? { isSuggestionDoc: true } : undefined,
+ );
+ try {
+ Y.applyUpdateV2(doc, content);
+ return { doc, fragment: findTypeInOtherYdoc(fragment, doc) };
+ } catch (error) {
+ // A failed decode must not leak the throwaway doc.
+ doc.destroy();
+ throw error;
+ }
+}
+
+/** Release a decoded throwaway doc. */
+export function destroyDecodedFragment(
+ decoded: DecodedFragment | undefined,
+): void {
+ decoded?.doc.destroy();
+}
diff --git a/packages/core/src/y/extensions/snapshotPreview.test.ts b/packages/core/src/y/extensions/snapshotPreview.test.ts
new file mode 100644
index 0000000000..708e8acd80
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotPreview.test.ts
@@ -0,0 +1,189 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import { configureYProsemirror } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { withCollaboration } from "./index.js";
+import { showSnapshotPreview } from "./snapshotPreview.js";
+
+/** Collaborative editor without versioning — the preview modules under test. */
+function createPreviewEditor() {
+ const doc = new Y.Doc();
+ const fragment = doc.get("doc");
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ user: { name: "Preview Test", color: "#0000ff" },
+ provider: undefined,
+ },
+ }),
+ );
+ editor.mount(document.createElement("div"));
+ return { editor, doc, fragment };
+}
+
+function getEditorText(editor: BlockNoteEditor): string {
+ return editor.prosemirrorState.doc.textContent;
+}
+
+function attributionMarkNames(
+ editor: BlockNoteEditor,
+): Set {
+ const names = new Set();
+ editor.prosemirrorState.doc.descendants((node) => {
+ node.marks.forEach((m) => {
+ if (m.type.name.startsWith("y-attributed-")) {
+ names.add(m.type.name);
+ }
+ });
+ return true;
+ });
+ return names;
+}
+
+/** Rebind the live fragment, as the versioning adapter does when leaving a preview. */
+function rebindLive(editor: BlockNoteEditor, fragment: Y.Node) {
+ editor.exec(configureYProsemirror({ ytype: fragment }));
+}
+
+describe("snapshot preview switching", () => {
+ let ctx: ReturnType;
+
+ afterEach(() => {
+ if (ctx) {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("rebinding live restores the document shown before a preview", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Live state" },
+ ]);
+ const snapshot = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Other state" },
+ ]);
+
+ // Previewing the older snapshot swaps the screen away from live.
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshot);
+ expect(getEditorText(ctx.editor)).toContain("Live state");
+
+ // The live Y.Doc still holds "Other state" (the preview renders a decoded
+ // copy); rebinding re-syncs the editor back to it.
+ rebindLive(ctx.editor, ctx.fragment);
+ expect(getEditorText(ctx.editor)).toContain("Other state");
+ });
+
+ it("successive previews switch content without touching the live doc", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 1" },
+ ]);
+ const v1 = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Version 2" },
+ ]);
+ const v2 = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, v1);
+ expect(getEditorText(ctx.editor)).toContain("Version 1");
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, v2);
+ expect(getEditorText(ctx.editor)).toContain("Version 2");
+
+ // The Y.Doc kept the last live state ("Version 2"), so rebinding shows it.
+ rebindLive(ctx.editor, ctx.fragment);
+ expect(getEditorText(ctx.editor)).toContain("Version 2");
+ });
+});
+
+describe("showSnapshotPreview", () => {
+ let ctx: ReturnType;
+
+ afterEach(() => {
+ if (ctx) {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("renders snapshot content without a baseline", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Original content" },
+ ]);
+ const snapshot = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Modified content" },
+ ]);
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshot);
+
+ expect(getEditorText(ctx.editor)).toContain("Original content");
+ expect(getEditorText(ctx.editor)).not.toContain("Modified");
+ });
+
+ it("renders insert/delete marks when diffed against a baseline", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "the quick brown fox" },
+ ]);
+ const baseline = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "the slow brown fox jumps" },
+ ]);
+ const snapshot = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshot, baseline);
+
+ const names = attributionMarkNames(ctx.editor);
+ expect(names.has("y-attributed-insert")).toBe(true);
+ expect(names.has("y-attributed-delete")).toBe(true);
+ expect(getEditorText(ctx.editor)).toContain("slow");
+ });
+
+ it("switches between successive previews", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot A" },
+ ]);
+ const snapshotA = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Snapshot B" },
+ ]);
+ const snapshotB = Y.encodeStateAsUpdateV2(ctx.doc);
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Current" },
+ ]);
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshotA);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot A");
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshotB);
+ expect(getEditorText(ctx.editor)).toContain("Snapshot B");
+ });
+
+ it("leaves the live document untouched when the snapshot is corrupt", () => {
+ ctx = createPreviewEditor();
+ ctx.editor.replaceBlocks(ctx.editor.document, [
+ { type: "paragraph", content: "Live content" },
+ ]);
+ const baseline = Y.encodeStateAsUpdateV2(ctx.doc);
+
+ expect(() =>
+ showSnapshotPreview(
+ ctx.editor,
+ ctx.fragment,
+ new Uint8Array([255, 255, 255]),
+ baseline,
+ ),
+ ).toThrow();
+ expect(getEditorText(ctx.editor)).toContain("Live content");
+ });
+});
diff --git a/packages/core/src/y/extensions/snapshotPreview.ts b/packages/core/src/y/extensions/snapshotPreview.ts
new file mode 100644
index 0000000000..b619689062
--- /dev/null
+++ b/packages/core/src/y/extensions/snapshotPreview.ts
@@ -0,0 +1,54 @@
+import { configureYProsemirror } from "@y/prosemirror";
+import * as Y from "@y/y";
+
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import {
+ decodeFragmentUpdate,
+ destroyDecodedFragment,
+} from "./snapshotCodec.js";
+
+/**
+ * Decode a snapshot, diff it against a baseline if given, and render it.
+ *
+ * Snapshots are decoded into throwaway documents, so pointing the binding at
+ * one is already an isolated state: the live fragment stops receiving view
+ * updates, and nothing typed into the preview reaches the live document.
+ * {@link configureYProsemirror} rebuilds the binding against the decoded
+ * fragment through the same `customCompare`/attribution pipeline the live
+ * binding uses; passing the `renderer` keeps the attribution tooltips
+ * ("who/when") working.
+ */
+export function showSnapshotPreview(
+ editor: BlockNoteEditor,
+ fragment: Y.Node,
+ snapshotContent: Uint8Array,
+ compareToContent?: Uint8Array,
+ attributions?: Y.ContentMap,
+): void {
+ const baseline = compareToContent
+ ? decodeFragmentUpdate(fragment, compareToContent, {
+ suggestionDoc: true,
+ })
+ : undefined;
+ try {
+ const snapshot = decodeFragmentUpdate(fragment, snapshotContent);
+ try {
+ editor.exec(
+ configureYProsemirror({
+ ytype: snapshot.fragment,
+ renderer: baseline
+ ? Y.createDiffRenderer(
+ baseline.doc,
+ snapshot.doc,
+ attributions ? { attributions } : undefined,
+ )
+ : undefined,
+ }),
+ );
+ } finally {
+ destroyDecodedFragment(snapshot);
+ }
+ } finally {
+ destroyDecodedFragment(baseline);
+ }
+}
diff --git a/packages/core/src/y/extensions/v1BindingSnapshotDiff.test.ts b/packages/core/src/y/extensions/v1BindingSnapshotDiff.test.ts
new file mode 100644
index 0000000000..49cdbab5eb
--- /dev/null
+++ b/packages/core/src/y/extensions/v1BindingSnapshotDiff.test.ts
@@ -0,0 +1,121 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import * as Y from "@y/y";
+import * as Y1 from "yjs";
+import { prosemirrorToYXmlFragment } from "y-prosemirror";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { withCollaboration } from "./index.js";
+import { decodeFragmentUpdate } from "./snapshotCodec.js";
+import { showSnapshotPreview } from "./snapshotPreview.js";
+
+/** Collaborative editor without versioning — the preview modules under test. */
+function createPreviewEditor() {
+ const doc = new Y.Doc();
+ const fragment = doc.get("doc");
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment,
+ user: { name: "Preview Test", color: "#0000ff" },
+ provider: undefined,
+ },
+ }),
+ );
+ editor.mount(document.createElement("div"));
+ return { editor, doc, fragment };
+}
+
+function getEditorText(editor: BlockNoteEditor): string {
+ return editor.prosemirrorState.doc.textContent;
+}
+
+function attributionMarkNames(
+ editor: BlockNoteEditor,
+): Set {
+ const names = new Set();
+ editor.prosemirrorState.doc.descendants((node) => {
+ node.marks.forEach((m) => {
+ if (m.type.name.startsWith("y-attributed-")) {
+ names.add(m.type.name);
+ }
+ });
+ return true;
+ });
+ return names;
+}
+
+/**
+ * Writes two prosemirror documents (at two points in time) into a single
+ * yjs v13 document using the old y-prosemirror binding, returning the two
+ * resulting state updates. Reusing one Y.Doc keeps both snapshots in the
+ * same ID space, so the new binding can diff them by ID.
+ */
+function buildV1Snapshots(editor: BlockNoteEditor): {
+ baseline: Uint8Array;
+ snapshot: Uint8Array;
+} {
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "Version 1" },
+ ]);
+ const pmDoc1 = editor.prosemirrorState.doc;
+
+ const legacyDoc = new Y1.Doc();
+ const legacyFragment = legacyDoc.get("doc", Y1.XmlFragment);
+ prosemirrorToYXmlFragment(pmDoc1, legacyFragment);
+ const baseline = Y1.encodeStateAsUpdateV2(legacyDoc);
+
+ editor.replaceBlocks(editor.document, [
+ { type: "paragraph", content: "Version 2" },
+ ]);
+ const pmDoc2 = editor.prosemirrorState.doc;
+ prosemirrorToYXmlFragment(pmDoc2, legacyFragment);
+ const snapshot = Y1.encodeStateAsUpdateV2(legacyDoc);
+
+ return { baseline, snapshot };
+}
+
+describe("v1 binding snapshot diff", () => {
+ let ctx: ReturnType;
+
+ afterEach(() => {
+ if (ctx) {
+ ctx.editor.unmount();
+ ctx.doc.destroy();
+ }
+ });
+
+ it("decodes a v1-shaped update into the nested anonymous node layout", () => {
+ ctx = createPreviewEditor();
+ const { baseline } = buildV1Snapshots(ctx.editor);
+
+ const decoded = decodeFragmentUpdate(ctx.fragment, baseline);
+ try {
+ const tree = JSON.stringify(decoded.fragment.toDelta());
+ expect(tree).toContain('"name":"blockGroup"');
+ expect(tree).toContain('"name":"paragraph"');
+ expect(tree).not.toContain('"name":null');
+ expect(tree).toMatch(
+ /"paragraph","children":\[\{"children":\["Version 1"\]/,
+ );
+ } finally {
+ decoded.doc.destroy();
+ }
+ });
+
+ it("diffs two v1-shaped snapshots through the new binding", () => {
+ ctx = createPreviewEditor();
+ const { baseline, snapshot } = buildV1Snapshots(ctx.editor);
+
+ showSnapshotPreview(ctx.editor, ctx.fragment, snapshot, baseline);
+
+ const names = attributionMarkNames(ctx.editor);
+ expect(names.has("y-attributed-insert")).toBe(true);
+ expect(names.has("y-attributed-delete")).toBe(true);
+ // The diff renders deleted text inline alongside inserted text: the deleted
+ // "1" and the inserted "2" both appear, each with its attribution mark.
+ expect(getEditorText(ctx.editor)).toBe("Version 12");
+ });
+});
diff --git a/packages/core/src/y/utils.test.ts b/packages/core/src/y/utils.test.ts
index edf242308e..2174c6ae53 100644
--- a/packages/core/src/y/utils.test.ts
+++ b/packages/core/src/y/utils.test.ts
@@ -1,15 +1,60 @@
import { Block, docToBlocks } from "../index.js";
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
-import { describe, expect, it } from "vite-plus/test";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import { docToDelta } from "@y/prosemirror";
+import type { Node } from "prosemirror-model";
+import { EditorState } from "prosemirror-state";
import * as Y from "@y/y";
+import { AttributionExtension } from "./extensions/AttributionExtension.js";
import {
_blocksToProsemirrorNode,
blocksToYDoc,
blocksToYType,
+ collectFragmentIds,
+ docDiffToDelta,
+ yNodeToTransaction,
yDocToBlocks,
yfragmentToBlocks,
} from "./utils.js";
+describe("collectFragmentIds", () => {
+ it.each(["document", "update"] as const)(
+ "collects deleted descendants from a %s without including other roots",
+ (input) => {
+ const doc = new Y.Doc({ gc: false });
+ const client = new Y.Doc();
+ try {
+ const fragment = doc.get("test");
+ const nested = new Y.Node();
+ fragment.push([nested]);
+ nested.push(["Deleted content"]);
+ const expected = Y.createContentIdsFromUpdate(
+ Y.encodeStateAsUpdate(doc),
+ ).inserts;
+ fragment.delete(0, 1);
+ doc.get("other").push(["Unrelated content"]);
+ const update = Y.encodeStateAsUpdate(doc);
+ Y.applyUpdate(client, update);
+
+ let destroyed = false;
+ doc.on("destroy", () => {
+ destroyed = true;
+ });
+ const ids = collectFragmentIds(
+ client.get("test"),
+ input === "document" ? doc : update,
+ );
+
+ expect(ids).toEqual(expected);
+ expect(destroyed).toBe(false);
+ } finally {
+ client.destroy();
+ doc.destroy();
+ }
+ },
+ );
+});
+
describe("Test y (v14) utils", () => {
const editor = BlockNoteEditor.create();
@@ -146,30 +191,16 @@ describe("Test y (v14) utils", () => {
expect(blockOutput).toEqual([]);
});
- // An empty block array round-trips through yjs to the canonical empty
- // BlockNote document: a single empty paragraph. (The id is generated, so we
- // normalize it before comparing.)
- const emptyDocument: Block[] = [
- {
- id: "0",
- type: "paragraph",
- props: {
- backgroundColor: "default",
- textColor: "default",
- textAlignment: "left",
- },
- content: [],
- children: [],
- },
- ];
- const normalizeIds = (blocks: Block[]) =>
- blocks.map((block) => ({ ...block, id: "0" }));
-
+ // An empty block array round-trips stably through yjs to an empty block
+ // array. (No phantom paragraph is materialized: the single empty
+ // paragraph that a mounted editor shows for an empty Y fragment comes
+ // from the schema's createAndFill initialBlockId stamp at mount time,
+ // which is deterministic across clients.)
it("empty document - converts to and from yjs (doc)", () => {
const blocks: Block[] = [];
const ydoc = blocksToYDoc(editor, blocks);
const blockOutput = yDocToBlocks(editor, ydoc);
- expect(normalizeIds(blockOutput)).toEqual(emptyDocument);
+ expect(blockOutput).toEqual([]);
});
it("empty document - converts to and from yjs (fragment)", () => {
@@ -179,7 +210,7 @@ describe("Test y (v14) utils", () => {
blocksToYType(editor, blocks, fragment);
const blockOutput = yfragmentToBlocks(editor, fragment);
- expect(normalizeIds(blockOutput)).toEqual(emptyDocument);
+ expect(blockOutput).toEqual([]);
});
});
@@ -1040,3 +1071,239 @@ describe("Test y (v14) utils", () => {
testConversion("complex mixed document", blocks);
});
});
+
+describe("yNodeToTransaction", () => {
+ const editor = BlockNoteEditor.create({
+ extensions: [AttributionExtension()],
+ });
+ const docs: Y.Doc[] = [];
+
+ afterEach(() => {
+ docs.splice(0).forEach((doc) => doc.destroy());
+ });
+
+ function paragraph(text: string) {
+ return _blocksToProsemirrorNode(editor, [
+ { id: "paragraph", type: "paragraph", content: text },
+ ]);
+ }
+
+ function createDiff(before: Node, after: Node, author: string) {
+ const baseline = new Y.Doc({ gc: false });
+ const target = new Y.Doc({ gc: false });
+ docs.push(baseline, target);
+ baseline.get("prosemirror").applyDelta(docToDelta(before));
+ Y.applyUpdateV2(target, Y.encodeStateAsUpdateV2(baseline));
+ const attributions = Y.createContentMap();
+ target.on("beforeObserverCalls", (tr) => {
+ Y.insertIntoIdMap(
+ attributions.inserts,
+ Y.createIdMapFromIdSet(tr.insertSet, [
+ Y.createContentAttribute("insert", author),
+ ]),
+ );
+ Y.insertIntoIdMap(
+ attributions.deletes,
+ Y.createIdMapFromIdSet(tr.deleteSet, [
+ Y.createContentAttribute("delete", author),
+ ]),
+ );
+ });
+ const node = target.get("prosemirror");
+ node.applyDelta(docDiffToDelta(before, after));
+ const renderer = Y.createDiffRenderer(baseline, target, { attributions });
+ return { node, renderer };
+ }
+
+ function expectTextAttributions(
+ doc: Node,
+ expected: {
+ plain: string;
+ inserted: string;
+ deleted: string;
+ author: string;
+ },
+ ) {
+ let plain = "";
+ let inserted = "";
+ let deleted = "";
+ doc.descendants((node) => {
+ if (!node.isText) {
+ return;
+ }
+ const marks = node.marks.filter((mark) =>
+ mark.type.name.startsWith("y-attributed-"),
+ );
+ if (marks.length === 0) {
+ plain += node.text;
+ } else {
+ expect(marks).toHaveLength(1);
+ const mark = marks[0];
+ expect(mark.attrs.userIds).toEqual([expected.author]);
+ expect(["y-attributed-insert", "y-attributed-delete"]).toContain(
+ mark.type.name,
+ );
+ if (mark.type.name === "y-attributed-insert") {
+ inserted += node.text;
+ } else {
+ deleted += node.text;
+ }
+ }
+ });
+ expect({ plain, inserted, deleted }).toEqual({
+ plain: expected.plain,
+ inserted: expected.inserted,
+ deleted: expected.deleted,
+ });
+ doc.check();
+ }
+
+ it("renders insertions and deletions over a normal document without writing to Y", () => {
+ const before = paragraph("kept old");
+ const { node, renderer } = createDiff(
+ before,
+ paragraph("kept NEW"),
+ "alice",
+ );
+ const update = Y.encodeStateAsUpdateV2(node.doc!);
+ const state = EditorState.create({ doc: before });
+ const tr = state.tr;
+
+ expect(yNodeToTransaction(tr, node, { renderer })).toBe(tr);
+ expectTextAttributions(state.apply(tr).doc, {
+ plain: "kept ",
+ inserted: "NEW",
+ deleted: "old",
+ author: "alice",
+ });
+ expect(tr.getMeta("y-sync-hydration")?.delta).toBeDefined();
+ expect(Y.encodeStateAsUpdateV2(node.doc!)).toEqual(update);
+ expect(state.doc.eq(before)).toBe(true);
+ });
+
+ it("replaces an attributed preview without retaining its content or marks", () => {
+ const before = paragraph("kept old");
+ const first = createDiff(before, paragraph("kept NEW"), "alice");
+ const second = createDiff(before, paragraph("kept XYZ"), "bob");
+ const initial = EditorState.create({ doc: before });
+ const preview = initial.apply(
+ yNodeToTransaction(initial.tr, first.node, first),
+ );
+ const tr = yNodeToTransaction(preview.tr, second.node, second);
+
+ expectTextAttributions(preview.apply(tr).doc, {
+ plain: "kept ",
+ inserted: "XYZ",
+ deleted: "old",
+ author: "bob",
+ });
+ const fresh = yNodeToTransaction(initial.tr, second.node, second);
+ expect(tr.doc.eq(fresh.doc)).toBe(true);
+ expect(tr.doc.textContent).not.toContain("NEW");
+ const next = preview.apply(tr);
+ expect(yNodeToTransaction(next.tr, second.node, second).steps).toHaveLength(
+ 0,
+ );
+ });
+
+ it("updates attribution when the rendered text is unchanged", () => {
+ const before = paragraph("kept old");
+ const after = paragraph("kept NEW");
+ const first = createDiff(before, after, "alice");
+ const second = createDiff(before, after, "bob");
+ const initial = EditorState.create({ doc: before });
+ const preview = initial.apply(
+ yNodeToTransaction(initial.tr, first.node, first),
+ );
+ const tr = yNodeToTransaction(preview.tr, second.node, second);
+
+ expect(tr.doc.textContent).toBe(preview.doc.textContent);
+ expect(tr.docChanged).toBe(true);
+ expectTextAttributions(preview.apply(tr).doc, {
+ plain: "kept ",
+ inserted: "NEW",
+ deleted: "old",
+ author: "bob",
+ });
+ });
+
+ it("removes attribution for a plain render and is a no-op when rendered again", () => {
+ const before = paragraph("kept old");
+ const after = paragraph("kept NEW");
+ const diff = createDiff(before, after, "alice");
+ const initial = EditorState.create({ doc: before });
+ const preview = initial.apply(
+ yNodeToTransaction(initial.tr, diff.node, diff),
+ );
+ const tr = yNodeToTransaction(preview.tr, diff.node);
+
+ expect(tr.doc.eq(after)).toBe(true);
+ const restored = preview.apply(tr);
+ expect(yNodeToTransaction(restored.tr, diff.node).steps).toHaveLength(0);
+ });
+
+ it("diffs from the transaction's current document and preserves existing steps and metadata", () => {
+ const before = paragraph("kept old");
+ const diff = createDiff(before, paragraph("kept NEW"), "alice");
+ const state = EditorState.create({ doc: before });
+ const tr = state.tr.insertText("temporary", 3).setMeta("caller", "preview");
+ const firstStep = tr.steps[0];
+
+ yNodeToTransaction(tr, diff.node, diff);
+
+ expect(tr.steps[0]).toBe(firstStep);
+ expect(tr.getMeta("caller")).toBe("preview");
+ expectTextAttributions(state.apply(tr).doc, {
+ plain: "kept ",
+ inserted: "NEW",
+ deleted: "old",
+ author: "alice",
+ });
+ });
+
+ it("switches attribution on replaced blocks while preserving their formatting", () => {
+ const before = paragraph("kept old");
+ const after = _blocksToProsemirrorNode(editor, [
+ {
+ id: "paragraph",
+ type: "heading",
+ props: { level: 2 },
+ content: [{ type: "text", text: "NEW", styles: { bold: true } }],
+ },
+ ]);
+ const first = createDiff(before, after, "alice");
+ const second = createDiff(before, after, "bob");
+ const state = EditorState.create({ doc: before });
+ const preview = state.apply(
+ yNodeToTransaction(state.tr, first.node, first),
+ );
+ const tr = yNodeToTransaction(preview.tr, second.node, second);
+
+ tr.doc.check();
+ const group = tr.doc.firstChild!;
+ expect(group.childCount).toBe(2);
+ const deleted = group.child(0);
+ const inserted = group.child(1);
+ expect(deleted.firstChild!.type.name).toBe("paragraph");
+ expect(
+ deleted.marks
+ .filter((mark) => mark.type.name === "y-attributed-delete")
+ .map((mark) => mark.toJSON()),
+ ).toEqual([{ type: "y-attributed-delete", attrs: { userIds: ["bob"] } }]);
+ expect(
+ inserted.marks
+ .filter((mark) => mark.type.name === "y-attributed-insert")
+ .map((mark) => mark.toJSON()),
+ ).toEqual([{ type: "y-attributed-insert", attrs: { userIds: ["bob"] } }]);
+ const heading = inserted.firstChild!;
+ expect(heading.type.name).toBe("heading");
+ expect(heading.attrs.level).toBe(2);
+ expect(heading.firstChild!.marks.map((mark) => mark.type.name)).toContain(
+ "bold",
+ );
+ expect(heading.textContent).toBe("NEW");
+ expect(
+ tr.doc.eq(yNodeToTransaction(state.tr, second.node, second).doc),
+ ).toBe(true);
+ });
+});
diff --git a/packages/core/src/y/utils.ts b/packages/core/src/y/utils.ts
index 241d934c74..c447c44678 100644
--- a/packages/core/src/y/utils.ts
+++ b/packages/core/src/y/utils.ts
@@ -1,14 +1,15 @@
import {
- deltaAttributionToFormat,
+ defaultTransformer,
deltaToPNode,
deltaToPSteps,
docToDelta,
nodeToDelta,
- pmToFragment,
+ pmnodeToDelta,
+ ynodeToPmnode,
} from "@y/prosemirror";
import * as d from "lib0/delta";
import { Node } from "prosemirror-model";
-import { Transaction } from "prosemirror-state";
+import type { Transaction } from "prosemirror-state";
import {
type Block,
type BlockNoteEditor,
@@ -25,12 +26,12 @@ import { mapAttributionToMark } from "./extensions/YSync.js";
import * as Y from "@y/y";
/**
- * Find the equivalent of a Y.Type in another Y.Doc.
+ * Find the equivalent of a Y.Node in another Y.Doc.
*
* For root types this looks up the matching shared key; for sub-types it
* locates the item by its client/clock ID in the target doc's store.
*/
-export function findTypeInOtherYdoc>(
+export function findTypeInOtherYdoc>(
ytype: T,
otherYdoc: Y.Doc,
): T {
@@ -46,10 +47,10 @@ export function findTypeInOtherYdoc>(
const rootKey = Array.from(ydoc.share.keys()).find(
(key) => ydoc.share.get(key) === ytype,
);
- if (rootKey == null) {
+ if (typeof rootKey !== "string") {
throw new Error("type does not exist in other ydoc");
}
- return otherYdoc.get(rootKey as string, ytype.constructor as any) as T;
+ return otherYdoc.get(rootKey, ytype.name) as T;
} else {
/**
* If it is a sub type, we use the item id to find the history type.
@@ -69,6 +70,44 @@ export function findTypeInOtherYdoc>(
}
}
+/**
+ * Collect all stored Yjs ID ranges belonging to a fragment, including deleted
+ * descendants. Items belonging to other shared types are excluded.
+ *
+ * Resolves the fragment in the supplied document and scans its stored items:
+ * walking visible content would miss deleted subtrees. To include all deleted
+ * descendants, the document or update must retain them; content already
+ * garbage-collected cannot be recovered here.
+ *
+ * Updates are loaded into a temporary document with `gc: false`, which is
+ * destroyed after collection. Caller-provided documents are never destroyed.
+ */
+export function collectFragmentIds(
+ fragment: Y.Node,
+ docOrUpdate: Uint8Array | Y.Doc,
+): Y.IdSet {
+ if (docOrUpdate instanceof Uint8Array) {
+ const doc = new Y.Doc({ gc: false });
+ try {
+ Y.applyUpdate(doc, docOrUpdate);
+ return collectFragmentIds(fragment, doc);
+ } finally {
+ doc.destroy();
+ }
+ }
+
+ const targetFragment = findTypeInOtherYdoc(fragment, docOrUpdate);
+ const contentIds = Y.createIdSet();
+ for (const structs of docOrUpdate.store.clients.values()) {
+ for (const item of structs) {
+ if (item instanceof Y.Item && Y.isParentOf(targetFragment, item)) {
+ contentIds.add(item.id.client, item.id.clock, item.length);
+ }
+ }
+ }
+ return contentIds;
+}
+
/**
* Turn Prosemirror JSON to BlockNote style JSON
* @param editor BlockNote editor
@@ -112,25 +151,78 @@ export function _blocksToProsemirrorNode<
/** YJS / BLOCKNOTE conversions */
/**
- * Turn a Y.Type collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
+ * Whether a `toDeltaDeep()` tree contains a `blockContainer` node.
+ *
+ * The delta is an untyped tree from an external library whose child
+ * collections may be plain arrays or lib0 `List`s, so this walks it
+ * structurally (via the iteration protocol) instead of relying on its types.
+ */
+function deltaHasBlockContainer(node: unknown): boolean {
+ if (typeof node !== "object" || node === null) {
+ return false;
+ }
+ const record = node as Record;
+ if (record["name"] === "blockContainer") {
+ return true;
+ }
+ return (
+ deltaChildrenContainBlock(record["children"]) ||
+ deltaChildrenContainBlock(record["insert"])
+ );
+}
+
+function deltaChildrenContainBlock(value: unknown): boolean {
+ // Arrays and lib0 `List`s both satisfy the iteration protocol, and
+ // everything else (strings are excluded by the typeof check, plain
+ // attribute values, null) is skipped.
+ if (
+ typeof value !== "object" ||
+ value === null ||
+ !(Symbol.iterator in value)
+ ) {
+ return false;
+ }
+ for (const child of value as Iterable) {
+ if (deltaHasBlockContainer(child)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Turn a Y.Node collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
* @param editor BlockNote editor
- * @param fragment Y.Type
+ * @param fragment Y.Node
* @returns BlockNote document (BlockNote style JSON of all blocks)
*/
export function yfragmentToBlocks<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
SSchema extends StyleSchema,
->(editor: BlockNoteEditor, fragment: Y.Type) {
- const pmNode = deltaToPNode(fragment.toDeltaDeep(), editor.pmSchema, null);
- if (pmNode === null) {
+>(editor: BlockNoteEditor, fragment: Y.Node) {
+ // Docs seeded by the current `blocksToYDoc([])` are pristine-empty: no Y
+ // children at all. This covers the common case without allocating a delta;
+ // the structural check below only remains for pre-existing fragments (see
+ // below).
+ if (fragment.length === 0) {
+ return [];
+ }
+ const delta = fragment.toDeltaDeep();
+ // A fragment without block containers holds no blocks — e.g. one written
+ // by an older `blocksToYDoc([])` (which used to write a childless block
+ // group). Returning early avoids materializing the schema filler paragraph
+ // in `deltaToPNode` below, whose id would be freshly minted on every
+ // read — making empty docs unstable.
+ if (!deltaHasBlockContainer(delta)) {
return [];
}
+ const pmNode = deltaToPNode(delta, editor.pmSchema, null);
return docToBlocks(pmNode);
}
/**
- * Convert blocks to a Y.Type
+ * Convert blocks to a Y.Node
*
* This can be used when importing existing content to Y.Doc for the first time,
* note that this should not be used to rehydrate a Y.Doc from a database once
@@ -139,7 +231,7 @@ export function yfragmentToBlocks<
* @param editor BlockNote editor
* @param blocks the blocks to convert
* @param fragment XML fragment name
- * @returns Y.Type
+ * @returns Y.Node
*/
export function blocksToYType<
BSchema extends BlockSchema,
@@ -148,12 +240,22 @@ export function blocksToYType<
>(
editor: BlockNoteEditor,
blocks: Block[],
- fragment?: Y.Type,
+ fragment?: Y.Node,
) {
if (!fragment) {
fragment = new Y.Doc().get("prosemirror");
}
- return pmToFragment(_blocksToProsemirrorNode(editor, blocks), fragment);
+ // An empty block array writes nothing: the fragment stays pristine-empty
+ // (no childless block group). This matters for the sync layer — the
+ // @y/prosemirror initial-content gate only engages when the ytype has no
+ // children (`ytype.length === 0`), keeping each mount's schema-default
+ // skeleton local instead of committing a competing paragraph item per
+ // client (the init race). See `EmptyDocBinding.test.ts`.
+ if (blocks.length === 0) {
+ return fragment;
+ }
+ fragment.applyDelta(pmnodeToDelta(_blocksToProsemirrorNode(editor, blocks)));
+ return fragment;
}
/**
@@ -193,8 +295,14 @@ export function blocksToYDoc<
blocks: PartialBlock[],
fragment = "prosemirror",
) {
- const delta = docToDelta(_blocksToProsemirrorNode(editor, blocks));
const doc = new Y.Doc();
+ // An empty block array seeds a pristine-empty fragment (see
+ // `blocksToYType` above for why writing nothing matters).
+ if (blocks.length === 0) {
+ doc.get(fragment);
+ return doc;
+ }
+ const delta = docToDelta(_blocksToProsemirrorNode(editor, blocks));
doc.get(fragment).applyDelta(delta);
return doc;
}
@@ -213,30 +321,23 @@ export function docDiffToDelta(previousDoc: Node, newDoc: Node) {
}
/**
- * Build a ProseMirror transaction that turns `tr.doc` into the content of a
- * Y.Type `fragment`, applying the `renderer`'s authorship as
- * `y-attributed-*` marks. Used to render a (read-only) diff of a snapshot / a
- * version comparison into the editor.
+ * Append steps that render a Y node into the transaction's current document.
+ * Supports both plain and already-attributed documents, replacing old preview
+ * marks with the renderer's attributions using BlockNote's node-pairing policy.
+ * Defaults to BlockNote's attribution transformer. Does not dispatch or write
+ * to the Y node; the caller controls sync configuration and dispatch.
*/
-export function getProseMirrorTrFromYFragment({
- tr,
- fragment,
- renderer,
-}: {
- tr: Transaction;
- fragment: Y.Type;
- renderer?: Y.AbstractRenderer | null;
-}): Transaction {
- const ycontent = deltaAttributionToFormat(
- fragment.toDeltaDeep({ renderer }),
- mapAttributionToMark,
- );
- // @todo it is preferred to apply the minimal diff - at least for debugging purposes. the
- // document replacal is more reliable though
-
- const pcontent = nodeToDelta(tr.doc, undefined, true);
- const diff = d.diff(pcontent.done(), ycontent.done(), {
- compare: blockMatchNodes,
+export function yNodeToTransaction(
+ tr: Transaction,
+ node: Y.Node,
+ options: NonNullable[2]> = {},
+): Transaction {
+ const renderedDoc = ynodeToPmnode(node, tr.doc.type.schema, {
+ transformer: defaultTransformer({ mapAttributionToMark }),
+ ...options,
+ });
+ const renderedDelta = docDiffToDelta(tr.doc, renderedDoc);
+ return deltaToPSteps(tr, renderedDelta).setMeta("y-sync-hydration", {
+ delta: renderedDelta,
});
- return deltaToPSteps(tr, diff, undefined, undefined);
}
diff --git a/packages/core/src/y/versioning/YHubVersionStore.ts b/packages/core/src/y/versioning/YHubVersionStore.ts
new file mode 100644
index 0000000000..6babb1ab85
--- /dev/null
+++ b/packages/core/src/y/versioning/YHubVersionStore.ts
@@ -0,0 +1,73 @@
+import type * as Y from "@y/y";
+import * as schema from "lib0/schema";
+import { assert } from "lib0/error";
+
+// An array avoids map-key tombstones. The last entry for each timestamp wins.
+const VERSIONS_ARRAY = "__bn_versions";
+
+const $versionEntry = schema.$object({
+ id: schema.$number,
+ name: schema.$string.optional,
+ restoredFrom: schema.$number.optional,
+});
+
+export type YHubVersionEntry = schema.Unwrap &
+ Record;
+
+/** Metadata stored in the live document, resolved fresh for each operation. */
+export class YHubVersionStore {
+ constructor(private readonly getDoc: () => Y.Doc | undefined) {}
+
+ getArray(): Y.Node {
+ const array = this.getDoc()?.get(VERSIONS_ARRAY);
+ assert(array != null);
+ return array!;
+ }
+
+ readEntries(): Map {
+ const entries = new Map();
+ const elements: unknown[] =
+ this.getDoc()?.get(VERSIONS_ARRAY).toArray() ?? [];
+ for (const element of elements) {
+ if ($versionEntry.check(element)) {
+ entries.set(element.id, element);
+ }
+ }
+ return entries;
+ }
+
+ private deleteEntryElements(array: Y.Node, id: number): void {
+ const elements: unknown[] = array.toArray();
+ // Back to front so the indices of the not-yet-visited elements hold.
+ for (let i = elements.length - 1; i >= 0; i--) {
+ const element = elements[i];
+ if ($versionEntry.check(element) && element.id === id) {
+ array.delete(i, 1);
+ }
+ }
+ }
+
+ upsertEntry(entry: YHubVersionEntry) {
+ const array = this.getArray();
+ // One transaction, so peers observe the delete and the push as a single
+ // change rather than a beat in which the entry is nowhere at all.
+ array.doc!.transact(() => {
+ this.deleteEntryElements(array, entry.id);
+ array.push([entry] as never);
+ });
+ }
+
+ setName(id: number, name: string | undefined) {
+ const next: YHubVersionEntry = { ...this.readEntries().get(id), id };
+ if (name) {
+ next.name = name;
+ } else {
+ delete next.name;
+ }
+ if (Object.keys(next).length === 1) {
+ this.deleteEntryElements(this.getArray(), id);
+ return;
+ }
+ this.upsertEntry(next);
+ }
+}
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json
index 5e4ef30b6f..374116f6fa 100644
--- a/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-all-after.json
@@ -2,7 +2,7 @@
{
"from": 1782218211312,
"to": 1782218211312,
- "by": "Dilbert Adams",
+ "by": ["Dilbert Adams"],
"customAttributions": [
{
"k": "type",
@@ -17,7 +17,7 @@
{
"from": 1782218082853,
"to": 1782218082853,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": [
{
"k": "type",
@@ -32,19 +32,19 @@
{
"from": 1782217704391,
"to": 1782217705077,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
},
{
"from": 1782217702869,
"to": 1782217703318,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
},
{
"from": 1782217700507,
"to": 1782217700507,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
}
]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-all.json b/packages/core/src/y/versioning/__test__/fixtures/activity-all.json
index 8d1623156e..c51e5a2d6f 100644
--- a/packages/core/src/y/versioning/__test__/fixtures/activity-all.json
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-all.json
@@ -2,19 +2,19 @@
{
"from": 1782217704391,
"to": 1782217705077,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
},
{
"from": 1782217702869,
"to": 1782217703318,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
},
{
"from": 1782217700507,
"to": 1782217700507,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": []
}
]
diff --git a/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json
index c08d5f49a0..75ec91ba7c 100644
--- a/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json
+++ b/packages/core/src/y/versioning/__test__/fixtures/activity-filtered-after.json
@@ -2,7 +2,7 @@
{
"from": 1782218211312,
"to": 1782218211312,
- "by": "Dilbert Adams",
+ "by": ["Dilbert Adams"],
"customAttributions": [
{
"k": "type",
@@ -17,7 +17,7 @@
{
"from": 1782218082853,
"to": 1782218082853,
- "by": "Charlie Brown",
+ "by": ["Charlie Brown"],
"customAttributions": [
{
"k": "type",
diff --git a/packages/core/src/y/versioning/__test__/yhub.test.ts b/packages/core/src/y/versioning/__test__/yhub.test.ts
index f7c6e55460..cd5b17bba6 100644
--- a/packages/core/src/y/versioning/__test__/yhub.test.ts
+++ b/packages/core/src/y/versioning/__test__/yhub.test.ts
@@ -6,66 +6,46 @@ import {
it,
vi,
} from "vite-plus/test";
-import { encodeAny } from "lib0/buffer";
+import { decodeAny, encodeAny } from "lib0/buffer";
import * as Y from "@y/y";
-import {
- CURRENT_VERSION_ID,
- type VersionSnapshot,
-} from "../../../extensions/Versioning/index.js";
+import type { VersionSnapshot } from "../../../extensions/Versioning/index.js";
import { createYHubVersioningEndpoints } from "../yhub.js";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { createExtension } from "../../../editor/BlockNoteExtension.js";
+import { en } from "../../../i18n/locales/en.js";
// ---------------------------------------------------------------------------
-// Fixture data — version entries now carry an `id` custom attribution (UUID).
+// Fixture data — an activity entry's `to` is the version's identity.
// ---------------------------------------------------------------------------
-const VERSION_ENTRY_1 = {
+const ENTRY_1 = {
from: 1782218082853,
to: 1782218082853,
- by: "user-1",
- customAttributions: [
- { k: "type", v: "version" },
- { k: "id", v: "uuid-version-1" },
- { k: "name", v: "Test Version 1" },
- ],
+ by: ["user-1"],
};
-const VERSION_ENTRY_2 = {
+const ENTRY_2 = {
from: 1782218211312,
to: 1782218211312,
- by: "user-2, user-3",
- customAttributions: [
- { k: "type", v: "version" },
- { k: "id", v: "uuid-version-2" },
- { k: "name", v: "Test Version 2" },
- ],
+ by: ["user-2", "user-3"],
};
-// Snapshots as produced by `list()` (see `activityToSnapshot`): the activity
-// entry's `to` timestamp becomes both `createdAt` and `updatedAt`. The
-// changeset/rollback APIs are now driven by these timestamps directly, so the
-// endpoints no longer make an activity lookup to resolve them. The entry's
-// comma-separated `by` user-ids are split into the snapshot's raw `by` array.
+// Snapshots as produced by `list()` (see `activityToSnapshot`): `id` is the
+// stringified `to`, which is also `createdAt`. Cross-user grouping makes `by`
+// an array, which becomes the version's raw `by` array.
const SNAPSHOT_1: VersionSnapshot = {
- id: "uuid-version-1",
- name: "Test Version 1",
- createdAt: VERSION_ENTRY_1.to,
- updatedAt: VERSION_ENTRY_1.to,
+ id: String(ENTRY_1.to),
+ createdAt: ENTRY_1.to,
by: ["user-1"],
};
const SNAPSHOT_2: VersionSnapshot = {
- id: "uuid-version-2",
- name: "Test Version 2",
- createdAt: VERSION_ENTRY_2.to,
- updatedAt: VERSION_ENTRY_2.to,
+ id: String(ENTRY_2.to),
+ createdAt: ENTRY_2.to,
by: ["user-2", "user-3"],
};
-const PATCH_RESPONSE = { success: true, message: "Document updated" };
-
function makeChangeset(opts: { ydoc?: boolean; attributions?: boolean } = {}) {
const doc = new Y.Doc();
const frag = doc.get("default", "XmlFragment");
@@ -85,34 +65,33 @@ const ORG = "test-org";
const DOC_ID = "test-doc";
// The factory returns a callback that receives the editor instance (used to
-// stamp `create`d snapshots with the current cursor user's id). Author ids are
-// passed through raw on `VersionSnapshot.by` — resolving them to user info is
-// the view layer's job, not the endpoints'. These tests create a bare editor
-// with no collaboration extensions, so `create` gets no author id.
+// reach the live collaboration doc that holds the named-version array). These
+// endpoints are built on a bare editor with no collaboration extensions, so
+// only the read-only paths work on them.
function makeEndpoints() {
const editor = BlockNoteEditor.create();
return createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
- activityLimit: 50,
})(editor);
}
-// A lightweight stand-in for the real `ySync` extension. `getVersionNamesMap`
-// in yhub.ts reads the live collaboration doc exclusively via
+// A lightweight stand-in for the real `ySync` extension. yhub.ts reaches the
+// live collaboration doc exclusively via
// `editor.getExtension("ySync")?.fragment.doc`, so a stub that just exposes the
-// fragment is enough to exercise the mutable `__bn_version_names` name store
-// without wiring up the full collaboration/prosemirror sync machinery.
-const ySyncStub = (fragment: Y.Type) =>
+// fragment is enough to exercise the `__bn_versions` store without wiring up
+// the full collaboration/prosemirror sync machinery.
+const ySyncStub = (fragment: Y.Node) =>
createExtension({ key: "ySync", fragment } as any);
// Build endpoints against an editor that has a `ySync` extension whose fragment
-// belongs to `doc`, so the mutable version-name store on `doc` is reachable.
-function makeCollabEndpoints(doc: Y.Doc) {
- const fragment = doc.get("default", "XmlFragment") as unknown as Y.Type;
+// belongs to `doc`, so the named-version array on `doc` is reachable.
+function makeCollabEndpoints(doc: Y.Doc, dictionary = en) {
+ const fragment = doc.get("default", "XmlFragment") as unknown as Y.Node;
(fragment as any).insert(0, ["hello"]);
const editor = BlockNoteEditor.create({
+ dictionary,
extensions: [ySyncStub(fragment)],
});
const endpoints = createYHubVersioningEndpoints({
@@ -123,6 +102,11 @@ function makeCollabEndpoints(doc: Y.Doc) {
return { endpoints, fragment };
}
+/** The raw contents of the `__bn_versions` array on `doc`. */
+function versionEntries(doc: Y.Doc): Array> {
+ return doc.get("__bn_versions").toArray() as Array>;
+}
+
function mockFetchResponse(body: unknown, status = 200) {
const encoded = encodeAny(body);
return new Response(encoded as Blob | BufferSource, {
@@ -131,7 +115,7 @@ function mockFetchResponse(body: unknown, status = 200) {
});
}
-function makeFragment(): Y.Type {
+function makeFragment(): Y.Node {
const doc = new Y.Doc();
const frag = doc.get("default", "XmlFragment");
frag.insert(0, ["test content"]);
@@ -157,448 +141,410 @@ describe("createYHubVersioningEndpoints", () => {
// list
// -------------------------------------------------------------------------
describe("list", () => {
- it("returns version-tagged entries using the id attribution as snapshot id", async () => {
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2, VERSION_ENTRY_1] }),
- );
- // Current-version probe: latest edit of any kind, then latest version
- // marker. Both are VERSION_ENTRY_2, so latest edit == latest marker → no
- // synthetic "current version" entry.
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
- );
+ it("makes a single activity request", async () => {
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
);
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
-
- expect(snapshots).toHaveLength(2);
- expect(snapshots[0].id).toBe("uuid-version-2");
- expect(snapshots[0].name).toBe("Test Version 2");
- // The comma-separated `by` user-ids are split into a raw id array —
- // never resolved to usernames here (that's the view layer's job).
- expect(snapshots[0].by).toEqual(["user-2", "user-3"]);
- expect(snapshots[0].secondaryLabel).toBeUndefined();
- expect(snapshots[1].id).toBe("uuid-version-1");
- expect(snapshots[1].name).toBe("Test Version 1");
- expect(snapshots[1].by).toEqual(["user-1"]);
- });
-
- it("fetches the full activity timeline (no type:version filter) with grouping defaults", async () => {
- // 1: full activity timeline. 2: latest edit of any kind. 3: latest
- // version marker (the current-version probe makes both 2 & 3).
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
+ await endpoints.list();
+
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`);
+ expect(url.searchParams.get("order")).toBe("desc");
+ expect(url.searchParams.get("limit")).toBe("50");
+ // Client clock skew must not hide server activity.
+ expect(url.searchParams.has("to")).toBe(false);
+ });
+
+ it("applies the product grouping defaults", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
const endpoints = makeEndpoints();
await endpoints.list();
- expect(fetchSpy).toHaveBeenCalledTimes(3);
- const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(versionUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`);
- // The `type:version` overlay filter is dropped so history entries are
- // returned too.
- expect(versionUrl.searchParams.get("withCustomAttributions")).toBe(null);
- expect(versionUrl.searchParams.get("customAttributions")).toBe("true");
- // Grouping default is applied.
- expect(versionUrl.searchParams.get("groupMaxGap")).toBe("10000");
-
- // Probe A: the latest entry of *any* type (no marker filter).
- const latestUrl = new URL(fetchSpy.mock.calls[1][0] as string);
- expect(latestUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`);
- expect(latestUrl.searchParams.get("limit")).toBe("1");
- expect(latestUrl.searchParams.has("withCustomAttributions")).toBe(false);
-
- // Probe B: the latest *version marker*, server-filtered to `type:version`
- // so grouping/mergeUsers can't conflate it with a later edit.
- const markerUrl = new URL(fetchSpy.mock.calls[2][0] as string);
- expect(markerUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`);
- expect(markerUrl.searchParams.get("limit")).toBe("1");
- expect(markerUrl.searchParams.get("withCustomAttributions")).toBe(
- "type:version",
- );
- });
-
- it("forwards group + groupMaxDuration params when configured", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("groupMaxGap")).toBe("3600000");
+ expect(url.searchParams.get("groupMaxDuration")).toBe("43200000");
+ expect(url.searchParams.get("groupByUser")).toBe("false");
+ expect(url.searchParams.get("customAttributions")).toBe("true");
+ // `group` is only forwarded when explicitly configured.
+ expect(url.searchParams.get("group")).toBe(null);
+ });
+
+ it("forwards explicitly configured grouping options", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
const endpoints = createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
- activityLimit: 50,
- group: true,
- groupMaxDuration: 5000,
+ activityParams: {
+ group: "true",
+ groupMaxGap: "1000",
+ groupMaxDuration: "5000",
+ groupByUser: "true",
+ },
})(BlockNoteEditor.create());
await endpoints.list();
- const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(versionUrl.searchParams.get("group")).toBe("true");
- expect(versionUrl.searchParams.get("groupMaxDuration")).toBe("5000");
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("group")).toBe("true");
+ expect(url.searchParams.get("groupMaxGap")).toBe("1000");
+ expect(url.searchParams.get("groupMaxDuration")).toBe("5000");
+ expect(url.searchParams.get("groupByUser")).toBe("true");
});
- it("omits group params by default while keeping the groupMaxGap default", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
-
- // `makeEndpoints` builds the factory with no group/groupMaxDuration opts.
- const endpoints = makeEndpoints();
- await endpoints.list();
-
- const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(versionUrl.searchParams.get("group")).toBe(null);
- expect(versionUrl.searchParams.get("groupMaxDuration")).toBe(null);
- expect(versionUrl.searchParams.get("groupMaxGap")).toBe("10000");
- // mergeUsers is only forwarded when explicitly configured.
- expect(versionUrl.searchParams.get("mergeUsers")).toBe(null);
- });
-
- it("maps both named version entries and plain history entries", async () => {
- const namedEntry = {
- from: 2000,
- to: 2000,
- by: "user-1",
- customAttributions: [
- { k: "type", v: "version" },
- { k: "id", v: "v1" },
- { k: "name", v: "Named" },
- ],
- };
- const historyEntry = {
- from: 1000,
- to: 1000,
- by: "user-2",
- };
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [namedEntry, historyEntry] }),
- );
- // Current-version probe: latest edit == latest marker == `namedEntry`, so
- // no synthetic current row.
+ it("makes the newest activity entry the current version", async () => {
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [namedEntry] }),
- );
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [namedEntry] }),
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
);
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { current, snapshots } = await endpoints.list();
- const named = snapshots.find((s) => s.name === "Named");
- expect(named).toBeDefined();
- expect(named!.id).toBe("v1");
-
- // `historyEntry` is at index 1 in the mocked entries array, so its
- // history id embeds that index: `history--`.
- const history = snapshots.find((s) => s.id === "history-1000-1");
- expect(history).toBeDefined();
- expect(history!.name).toBeUndefined();
+ expect(current).toEqual(SNAPSHOT_2);
+ expect(snapshots).toEqual([SNAPSHOT_1]);
});
- it("returns empty array when no versions exist", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
+ it("merges activity entries that share a `to`, unioning the authors", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({
+ activity: [
+ { from: 2000, to: 2000, by: ["user-1"] },
+ { from: 2000, to: 2000, by: ["user-2", "user-1"] },
+ { from: 1000, to: 1000, by: ["user-3"] },
+ ],
+ }),
+ );
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { current, snapshots } = await endpoints.list();
- expect(snapshots).toEqual([]);
+ expect(current).toEqual({
+ id: "2000",
+ createdAt: 2000,
+ by: ["user-1", "user-2"],
+ });
+ expect(snapshots).toHaveLength(1);
+ expect(snapshots[0]!.id).toBe("1000");
});
- it("sorts snapshots newest-first", async () => {
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1, VERSION_ENTRY_2] }),
- );
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
- );
+ it("normalizes scalar and unattributed activity authors", async () => {
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
+ mockFetchResponse({
+ activity: [
+ { from: 2000, to: 2000, by: "user-1, user-2" },
+ { from: 1000, to: 1000, by: [null, "user-3"] },
+ ],
+ }),
);
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { current, snapshots } = await endpoints.list();
- expect(snapshots[0].createdAt).toBeGreaterThan(snapshots[1].createdAt);
+ expect(current.by).toEqual(["user-1", "user-2"]);
+ expect(snapshots[0]!.by).toEqual(["user-3"]);
});
- it("silently skips entries without an id attribution", async () => {
- const noIdEntry = {
- from: 1782218082853,
- to: 1782218082853,
- by: "Bad Entry",
- customAttributions: [{ k: "type", v: "version" }],
- };
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1, noIdEntry] }),
- );
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1] }),
- );
+ it("surfaces an entry's custom attributions as metadata", async () => {
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1] }),
+ mockFetchResponse({
+ activity: [
+ {
+ from: 2000,
+ to: 2000,
+ customAttributions: [{ k: "source", v: "import" }],
+ },
+ {
+ // Same version, a second attribution: both are kept.
+ from: 2000,
+ to: 2000,
+ customAttributions: [{ k: "ticket", v: "BN-1" }],
+ },
+ ],
+ }),
);
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { current } = await endpoints.list();
- expect(snapshots).toHaveLength(1);
- expect(snapshots[0].id).toBe("uuid-version-1");
+ expect(current.metadata).toEqual({ source: "import", ticket: "BN-1" });
});
- it("prefers the mutable __bn_version_names name over the attribution name", async () => {
+ it("lets a stored entry override an activity attribution", async () => {
const doc = new Y.Doc();
- // The `ySync` extension's fragment belongs to `doc`, so the mutable
- // name store on `doc` is what `getVersionNamesMap` reads.
const { endpoints } = makeCollabEndpoints(doc);
- // Rename version "v1" in the mutable store on the live doc.
- doc.get("__bn_version_names").setAttr("v1", "Renamed");
-
- const versionEntry = {
- from: 1782218082853,
- to: 1782218082853,
- by: "user-1",
- customAttributions: [
- { k: "type", v: "version" },
- { k: "id", v: "v1" },
- { k: "name", v: "Original" },
- ],
- };
- // 1: activity fetch. 2 & 3: current-version probe (latest edit, latest
- // marker) — same entry both times, so no newer edit and no current row.
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [versionEntry] }),
- );
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [versionEntry] }),
- );
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, source: "restore" }]);
+
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [versionEntry] }),
+ mockFetchResponse({
+ activity: [
+ ENTRY_2,
+ { ...ENTRY_1, customAttributions: [{ k: "source", v: "import" }] },
+ ],
+ }),
);
- const snapshots = await endpoints.list();
+ const { snapshots } = await endpoints.list();
- expect(snapshots).toHaveLength(1);
- expect(snapshots[0].id).toBe("v1");
- expect(snapshots[0].name).toBe("Renamed");
+ expect(snapshots[0]!.metadata).toEqual({ source: "restore" });
});
- it("falls back to the attribution name when the store has no entry", async () => {
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1] }),
- );
+ it("overlays stored names, restoredFrom and metadata onto matching rows", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc
+ .get("__bn_versions")
+ .push([
+ { id: ENTRY_1.to, name: "Named", restoredFrom: 42, copyOf: "abc" },
+ ]);
+
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1] }),
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
);
+
+ const { snapshots } = await endpoints.list();
+
+ expect(snapshots).toEqual([
+ {
+ ...SNAPSHOT_1,
+ name: "Named",
+ // The stored `to` is the restored version's whole identity.
+ restoredFrom: { id: "42", createdAt: 42 },
+ metadata: { copyOf: "abc" },
+ },
+ ]);
+ });
+
+ it("keeps the last stored element for an id", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "First" }]);
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "Second" }]);
+
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_1] }),
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
);
- const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { snapshots } = await endpoints.list();
- expect(snapshots).toHaveLength(1);
- expect(snapshots[0].name).toBe("Test Version 1");
+ expect(snapshots[0]!.name).toBe("Second");
});
- it("prepends a 'current version' entry when there are edits beyond the latest version", async () => {
- // A more recent edit than VERSION_ENTRY_2, by a different author.
- const latestEdit = {
- from: 1782218300000,
- to: 1782218300000,
- by: "user-4",
- };
- // 1: activity list. 2: latest edit of any kind (the newer edit). 3: latest
- // version marker (VERSION_ENTRY_2). Since latestEdit.to > VERSION_ENTRY_2.to
- // a synthetic current-version row is prepended.
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2, VERSION_ENTRY_1] }),
- );
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [latestEdit] }),
- );
+ it("adds a row for a stored entry with no matching activity", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ const orphanTo = ENTRY_2.to + 1000;
+ doc.get("__bn_versions").push([{ id: orphanTo, name: "Orphan" }]);
+
fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
);
- const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
-
- expect(snapshots).toHaveLength(3);
- expect(snapshots[0].id).toBe(CURRENT_VERSION_ID);
- expect(snapshots[0].createdAt).toBe(latestEdit.to);
- expect(snapshots[0].by).toEqual(["user-4"]);
- expect(snapshots[0].secondaryLabel).toBeUndefined();
- // The real version markers follow, newest-first.
- expect(snapshots[1].id).toBe("uuid-version-2");
- expect(snapshots[2].id).toBe("uuid-version-1");
- });
-
- it("prepends 'current version' even when the newest activity entry is a history edit (regression: grouping absorbing the marker)", async () => {
- // This is the exact regression the self-contained current-version probe
- // fixes: `list()` maps the whole timeline, so its newest snapshot is a
- // plain edit — deriving the latest-version time from that list would make
- // the guard compare a value against itself and never surface a current
- // row. Because the marker is fetched independently (probe 3), the edit is
- // correctly recognised as newer than the latest *version marker*.
- const historyEdit = {
- from: 1782218300000,
- to: 1782218300000,
- by: "user-9",
- };
- // 1: activity list — newest entry is the history edit, not a marker.
+ const { current, snapshots } = await endpoints.list();
+
+ // Newer than every activity entry, yet the newest *activity* entry is
+ // still the current version — the orphan just sorts above the rest.
+ expect(current).toEqual(SNAPSHOT_2);
+ expect(snapshots.map((s) => s.id)).toEqual([
+ String(orphanTo),
+ SNAPSHOT_1.id,
+ ]);
+ expect(snapshots[0]).toEqual({
+ id: String(orphanTo),
+ createdAt: orphanTo,
+ name: "Orphan",
+ restoredFrom: undefined,
+ metadata: undefined,
+ });
+ });
+
+ it("sorts stored versions newest-first", async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse({
- activity: [historyEdit, VERSION_ENTRY_2, VERSION_ENTRY_1],
+ activity: [
+ { from: 1000, to: 1000 },
+ { from: 3000, to: 3000 },
+ { from: 2000, to: 2000 },
+ ],
}),
);
- // 2: latest edit of any kind → the history edit.
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [historyEdit] }),
- );
- // 3: latest version marker → VERSION_ENTRY_2 (older than the edit).
- fetchSpy.mockResolvedValueOnce(
- mockFetchResponse({ activity: [VERSION_ENTRY_2] }),
- );
const endpoints = makeEndpoints();
- const snapshots = await endpoints.list();
+ const { current, snapshots } = await endpoints.list();
- expect(snapshots[0].id).toBe(CURRENT_VERSION_ID);
- expect(snapshots[0].createdAt).toBe(historyEdit.to);
- expect(snapshots[0].by).toEqual(["user-9"]);
+ expect(current.createdAt).toBe(3000);
+ expect(snapshots.map((s) => s.createdAt)).toEqual([2000, 1000]);
});
- it("forwards the mergeUsers param when configured", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
+ it("falls back to an empty current row when there is no activity", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- const endpoints = createYHubVersioningEndpoints({
- baseUrl: BASE_URL,
- org: ORG,
- docId: DOC_ID,
- mergeUsers: true,
- })(BlockNoteEditor.create());
- await endpoints.list();
+ const endpoints = makeEndpoints();
+ const { current, snapshots } = await endpoints.list();
- const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(versionUrl.searchParams.get("mergeUsers")).toBe("true");
+ expect(current.id).toBe(String(current.createdAt));
+ expect(snapshots).toEqual([]);
});
});
// -------------------------------------------------------------------------
- // create
+ // create (name the current version)
// -------------------------------------------------------------------------
describe("create", () => {
- it("PATCHes with type:version, id, and name attributions and returns optimistic snapshot", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
+ it("names the listed Current, then accepts newer backend activity", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+ const newerActivity = {
+ from: ENTRY_2.to + 1000,
+ to: ENTRY_2.to + 1000,
+ };
- const endpoints = makeEndpoints();
- const snapshot = await endpoints.create!(makeFragment(), {
- name: "My Version",
- });
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
+ );
+ await endpoints.list();
- // Only one fetch call (PATCH) — no activity fetch
+ const named = await endpoints.create!(fragment, { name: "Milestone" });
expect(fetchSpy).toHaveBeenCalledOnce();
- const [patchUrl, patchInit] = fetchSpy.mock.calls[0];
- expect(patchUrl).toBe(`${BASE_URL}/ydoc/v1/${ORG}/${DOC_ID}`);
- expect(patchInit.method).toBe("PATCH");
- expect(patchInit.body).toBeInstanceOf(Uint8Array);
-
- // Optimistic snapshot has a UUID id and the provided name. With no
- // yCursor extension there's no current user to attribute it to.
- expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
- expect(snapshot.name).toBe("My Version");
- expect(snapshot.createdAt).toBeGreaterThan(0);
- expect(snapshot.by).toBeUndefined();
- });
-
- it("stamps the optimistic snapshot with the cursor user's id", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
-
- // Stub the editor so the yCursor extension reports a current user.
- const editor = {
- getExtension: (key: string) =>
- key === "yCursor"
- ? {
- getUser: () => ({ id: "user-1", name: "Alice", color: "#f00" }),
- }
- : undefined,
- } as unknown as BlockNoteEditor;
- const endpoints = createYHubVersioningEndpoints({
- baseUrl: BASE_URL,
- org: ORG,
- docId: DOC_ID,
- })(editor);
+ expect(named).toEqual({ ...SNAPSHOT_2, name: "Milestone" });
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({
+ activity: [newerActivity, ENTRY_2, ENTRY_1],
+ }),
+ );
+ const { current, snapshots } = await endpoints.list();
+ expect(current).toEqual({
+ id: String(newerActivity.to),
+ createdAt: newerActivity.to,
+ });
+ expect(snapshots).toEqual([named, SNAPSHOT_1]);
+ });
+
+ it("names the newest activity entry via a limit-1 fetch", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2] }),
+ );
- const snapshot = await endpoints.create!(makeFragment(), {
+ const snapshot = await endpoints.create!(fragment, {
name: "My Version",
});
- expect(snapshot.by).toBe("user-1");
- // The PATCH is attributed to the same user via the body, not query params.
- const patchUrl = new URL(fetchSpy.mock.calls[0][0] as string);
- expect(patchUrl.searchParams.has("userid")).toBe(false);
+ // No PATCH: naming a version only writes to the live doc.
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`);
+ expect(url.searchParams.get("limit")).toBe("1");
+ expect(url.searchParams.get("order")).toBe("desc");
+
+ expect(versionEntries(doc)).toEqual([
+ { id: ENTRY_2.to, name: "My Version" },
+ ]);
+ expect(snapshot).toEqual({ ...SNAPSHOT_2, name: "My Version" });
});
- it("creates a version without a name", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
+ it("keeps other metadata on the entry it names", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
- const endpoints = makeEndpoints();
- const snapshot = await endpoints.create!(makeFragment());
+ doc.get("__bn_versions").push([{ id: ENTRY_2.to, restoredFrom: 42 }]);
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2] }),
+ );
+
+ await endpoints.create!(fragment, { name: "After restore" });
- expect(snapshot.name).toBeUndefined();
- expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
+ expect(versionEntries(doc)).toEqual([
+ { id: ENTRY_2.to, restoredFrom: 42, name: "After restore" },
+ ]);
});
- it("writes the version name into the __bn_version_names Y.Map on create", async () => {
+ it("labels nothing when created without a name", async () => {
const doc = new Y.Doc();
const { endpoints, fragment } = makeCollabEndpoints(doc);
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2] }),
+ );
- const snapshot = await endpoints.create!(fragment as any, {
- name: "My Version",
+ const snapshot = await endpoints.create!(fragment, {});
+
+ // No name to store: writing a bare `{ id }` entry would be junk.
+ expect(versionEntries(doc)).toEqual([]);
+ expect(snapshot).toEqual(SNAPSHOT_2);
+ });
+
+ it("keeps an existing name when created without one", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+
+ doc.get("__bn_versions").push([{ id: ENTRY_2.to, name: "Existing" }]);
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2] }),
+ );
+
+ const snapshot = await endpoints.create!(fragment, {});
+
+ expect(versionEntries(doc)).toEqual([
+ { id: ENTRY_2.to, name: "Existing" },
+ ]);
+ expect(snapshot).toEqual({ ...SNAPSHOT_2, name: "Existing" });
+ });
+
+ it("labels the newest entry even when activity order is configured asc", async () => {
+ const doc = new Y.Doc();
+ const fragment = doc.get("default", "XmlFragment") as unknown as Y.Node;
+ (fragment as any).insert(0, ["hello"]);
+ const editor = BlockNoteEditor.create({
+ extensions: [ySyncStub(fragment)],
});
+ const endpoints = createYHubVersioningEndpoints({
+ baseUrl: BASE_URL,
+ org: ORG,
+ docId: DOC_ID,
+ activityParams: { order: "asc" },
+ })(editor);
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_1] }),
+ );
+
+ await endpoints.create!(fragment, { name: "Named" });
- const names = doc.get("__bn_version_names");
- expect(names.getAttr(snapshot.id as string)).toBe("My Version");
+ // The limit-1 lookup must force newest-first: an asc response would put
+ // the oldest entry first, and the name would land on the wrong row.
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("order")).toBe("desc");
+ expect(versionEntries(doc)).toEqual([{ id: ENTRY_1.to, name: "Named" }]);
});
- it("throws when the fragment is not attached to a doc", async () => {
+ it("throws when there is no live collaboration document", async () => {
const endpoints = makeEndpoints();
- const detached = { doc: null } as unknown as Y.Type;
await expect(
- endpoints.create!(detached, { name: "fail" }),
- ).rejects.toThrow("not attached to a Y.Doc");
+ endpoints.create!(makeFragment(), { name: "fail" }),
+ ).rejects.toThrow("Assert failed");
+ // It fails before making any request.
+ expect(fetchSpy).not.toHaveBeenCalled();
});
- it("getContent works on the returned snapshot without an extra lookup", async () => {
- fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
- const cs = makeChangeset();
- fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
-
- const endpoints = makeEndpoints();
- const snapshot = await endpoints.create!(makeFragment(), {
- name: "new",
- });
+ it("throws when YHub has recorded no activity", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] }));
- const content = await endpoints.getContent(snapshot);
- expect(content).toBeInstanceOf(Uint8Array);
- // PATCH + changeset — the timestamp comes from the snapshot itself, so
- // there's no activity lookup.
- expect(fetchSpy).toHaveBeenCalledTimes(2);
- const url = new URL(fetchSpy.mock.calls[1][0] as string);
- expect(url.searchParams.get("to")).toBe(String(snapshot.createdAt));
+ await expect(
+ endpoints.create!(fragment, { name: "fail" }),
+ ).rejects.toThrow("no activity");
});
});
@@ -607,7 +553,6 @@ describe("createYHubVersioningEndpoints", () => {
// -------------------------------------------------------------------------
describe("getContent", () => {
it("fetches the changeset by to= with no activity lookup", async () => {
- // changeset fetch
const cs = makeChangeset();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
@@ -617,16 +562,14 @@ describe("createYHubVersioningEndpoints", () => {
expect(content).toBeInstanceOf(Uint8Array);
expect(content.byteLength).toBeGreaterThan(0);
- // The snapshot carries its own timestamp, so only the changeset is fetched.
+ // The version carries its own timestamp, so only the changeset is fetched.
expect(fetchSpy).toHaveBeenCalledOnce();
- // changeset reconstructed by timestamp, NOT by custom attribution
const url = new URL(fetchSpy.mock.calls[0][0] as string);
expect(url.pathname).toBe(`/api/changeset/v1/${ORG}/${DOC_ID}`);
expect(url.searchParams.get("ydoc")).toBe("true");
expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
expect(url.searchParams.has("from")).toBe(false);
- expect(url.searchParams.has("withCustomAttributions")).toBe(false);
});
it("throws when changeset has no ydoc", async () => {
@@ -646,17 +589,18 @@ describe("createYHubVersioningEndpoints", () => {
it("fetches attributions between two versions", async () => {
const endpoints = makeEndpoints();
- // changeset fetch (timestamps come straight from the snapshots)
const cs = makeChangeset({ attributions: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
try {
- await endpoints.getAttributions!(SNAPSHOT_2, SNAPSHOT_1);
+ await endpoints.getAttributions!(
+ { kind: "snapshot", snapshot: SNAPSHOT_2 },
+ SNAPSHOT_1,
+ );
} catch {
// Expected — mock attributions aren't valid Y.ContentMap
}
- // Only the changeset is fetched — no activity lookups.
expect(fetchSpy).toHaveBeenCalledOnce();
const url = new URL(fetchSpy.mock.calls[0][0] as string);
expect(url.searchParams.get("from")).toBe(String(SNAPSHOT_1.createdAt));
@@ -664,15 +608,59 @@ describe("createYHubVersioningEndpoints", () => {
expect(url.searchParams.get("attributions")).toBe("true");
});
+ it("includes current-document attributions newer than the last listed version", async () => {
+ const endpoints = makeEndpoints();
+ const editTime = SNAPSHOT_2.createdAt + 1000;
+ const changes = Y.createIdSet();
+ changes.add(7, 0, 1);
+ const latestAttributions = Y.createContentMap();
+ Y.insertIntoIdMap(
+ latestAttributions.inserts,
+ Y.createIdMapFromIdSet(changes, [
+ Y.createContentAttribute("insert", "new-peer"),
+ ]),
+ );
+
+ fetchSpy.mockImplementation(
+ async (input: Parameters[0]) => {
+ const url = new URL(input instanceof Request ? input.url : input);
+ const to = url.searchParams.get("to");
+ // Model a server-side edit after the sidebar loaded its current row.
+ const attributions =
+ to === null || Number(to) >= editTime
+ ? latestAttributions
+ : Y.createContentMap();
+ return mockFetchResponse({
+ attributions: Y.encodeContentMap(attributions),
+ });
+ },
+ );
+
+ const attributions = await endpoints.getAttributions!(
+ { kind: "current", snapshot: SNAPSHOT_2 },
+ SNAPSHOT_1,
+ );
+
+ expect(Y.encodeContentMap(attributions)).toEqual(
+ Y.encodeContentMap(latestAttributions),
+ );
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const url = new URL(fetchSpy.mock.calls[0][0] as string);
+ expect(url.searchParams.get("from")).toBe(String(SNAPSHOT_1.createdAt));
+ expect(url.searchParams.has("to")).toBe(false);
+ });
+
it("uses from=0 when compareTo is omitted", async () => {
const endpoints = makeEndpoints();
- // changeset fetch
const cs = makeChangeset({ attributions: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
try {
- await endpoints.getAttributions!(SNAPSHOT_1);
+ await endpoints.getAttributions!({
+ kind: "snapshot",
+ snapshot: SNAPSHOT_1,
+ });
} catch {
// Expected
}
@@ -684,14 +672,16 @@ describe("createYHubVersioningEndpoints", () => {
it("throws when changeset has no attributions", async () => {
const endpoints = makeEndpoints();
- // changeset without attributions
fetchSpy.mockResolvedValueOnce(
mockFetchResponse({ ydoc: new Uint8Array() }),
);
- await expect(endpoints.getAttributions!(SNAPSHOT_1)).rejects.toThrow(
- "no attributions",
- );
+ await expect(
+ endpoints.getAttributions!({
+ kind: "snapshot",
+ snapshot: SNAPSHOT_1,
+ }),
+ ).rejects.toThrow("no attributions");
});
});
@@ -699,71 +689,351 @@ describe("createYHubVersioningEndpoints", () => {
// restore
// -------------------------------------------------------------------------
describe("restore", () => {
- it("fetches content and issues rollback (no backup)", async () => {
- const endpoints = makeEndpoints();
+ it("does not keep a named pre-restore Current pinned", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2, ENTRY_1] }),
+ );
+ await endpoints.list();
+ const named = await endpoints.create!(fragment, { name: "Milestone" });
+
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(makeChangeset()));
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [ENTRY_2] }),
+ );
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ doc: Y.encodeStateAsUpdate(doc) }),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
+ await endpoints.restore!(fragment, SNAPSHOT_1);
+
+ const restoredHead = ENTRY_2.to + 2000;
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({
+ activity: [
+ { from: restoredHead, to: restoredHead },
+ ENTRY_2,
+ ENTRY_1,
+ ],
+ }),
+ );
+ const { current, snapshots } = await endpoints.list();
+
+ expect(current.createdAt).toBe(restoredHead);
+ expect(snapshots).toContainEqual(named);
+ });
+
+ it("restores the exact boundary and deleted subtrees without reverting metadata or other roots", async () => {
+ const server = new Y.Doc({ gc: false });
+ const fragmentOnServer = server.get("default", "XmlFragment");
+ const nested = new Y.Node();
+ fragmentOnServer.push([nested]);
+ nested.push(["Original nested content"]);
+ const original = fragmentOnServer.toJSON();
+ const snapshotUpdate = Y.encodeStateAsUpdate(server);
+ const boundary = SNAPSHOT_1.createdAt;
+ const changes = [
+ { at: boundary, ids: Y.createContentIdsFromUpdate(snapshotUpdate) },
+ ];
+ server.on("update", (update: Uint8Array) => {
+ changes.push({
+ at: boundary + 1,
+ ids: Y.createContentIdsFromUpdate(update),
+ });
+ });
+ server.transact(() => {
+ fragmentOnServer.delete(0, 1);
+ fragmentOnServer.push(["New content"]);
+ server.get("other-editor").push(["Keep this document"]);
+ server
+ .get("__bn_versions")
+ .push([{ id: boundary, name: "Original version" }]);
+ });
+
+ // The client has GC enabled: it cannot discover the deleted subtree's
+ // children itself, but the server still retains them for restoration.
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+ fragment.delete(0, fragment.length);
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(server));
+ let rolledBack = false;
+ fetchSpy.mockImplementation(
+ async (...[input, init]: Parameters) => {
+ const url = new URL(input instanceof Request ? input.url : input);
+ if (url.pathname.includes("/changeset/")) {
+ return mockFetchResponse({ ydoc: snapshotUpdate });
+ }
+ if (url.pathname.includes("/activity/")) {
+ const at = boundary + (rolledBack ? 2000 : 1000);
+ return mockFetchResponse({ activity: [{ from: at, to: at }] });
+ }
+ if (url.pathname.includes("/ydoc/")) {
+ expect(url.searchParams.get("gc")).toBe("false");
+ return mockFetchResponse({ doc: Y.encodeStateAsUpdate(server) });
+ }
+ expect(url.pathname).toContain("/rollback/");
+ if (!(init?.body instanceof Uint8Array)) {
+ throw new Error("Expected an encoded rollback request");
+ }
+ const request = decodeAny(init.body) as {
+ from: number;
+ contentIds: Uint8Array;
+ };
+ expect(request.from).toBe(boundary + 1);
+ const reverted = Y.intersectContentIds(
+ Y.mergeContentIds(
+ changes
+ .filter((change) => change.at >= request.from)
+ .map((change) => change.ids),
+ ),
+ Y.decodeContentIds(request.contentIds),
+ );
+ Y.undoContentIds(server, reverted);
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(server));
+ rolledBack = true;
+ return mockFetchResponse({ success: true });
+ },
+ );
+
+ await endpoints.restore!(fragment, SNAPSHOT_1);
+
+ expect(fragment.toJSON()).toEqual(original);
+ expect(doc.get("other-editor").toArray()).toEqual(["Keep this document"]);
+ expect(versionEntries(doc)).toEqual([
+ { id: boundary, name: "Original version" },
+ { id: boundary + 1000, name: "Before restore" },
+ ]);
+ server.destroy();
+ doc.destroy();
+ });
+
+ it("fetches content, rolls back and preserves the last observed head", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc, {
+ ...en,
+ versioning: {
+ ...en.versioning,
+ before_restore: "Vor Wiederherstellung",
+ },
+ });
const cs = makeChangeset();
// 1: GET /changeset (getContentAt via to=)
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
- // 2: POST /rollback
+ // 2: the head before the rollback
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [{ from: 8000, to: 8000 }] }),
+ );
+ // 3: retained document history; 4: POST /rollback
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ doc: Y.encodeStateAsUpdate(doc) }),
+ );
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
- const content = await endpoints.restore!(makeFragment(), SNAPSHOT_1);
+ const content = await endpoints.restore!(fragment, SNAPSHOT_1);
- // No backup PATCH and no activity lookup — just changeset + rollback.
- expect(fetchSpy).toHaveBeenCalledTimes(2);
+ expect(fetchSpy).toHaveBeenCalledTimes(4);
- // 1st call: GET changeset by timestamp
const csUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(csUrl.pathname).toBe(`/api/changeset/v1/${ORG}/${DOC_ID}`);
expect(csUrl.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
- // 2nd call: POST rollback
- const [rollbackUrl, rollbackInit] = fetchSpy.mock.calls[1];
+ const [rollbackUrl, rollbackInit] = fetchSpy.mock.calls[3];
expect(rollbackUrl).toBe(`${BASE_URL}/rollback/v1/${ORG}/${DOC_ID}`);
expect(rollbackInit.method).toBe("POST");
+ // The pre-restore head is pinned by name: with the default grouping the
+ // rollback can merge into the same row, which would swallow the state
+ // being left behind.
+ expect(versionEntries(doc)).toEqual([
+ { id: 8000, name: "Vor Wiederherstellung" },
+ ]);
expect(content).toBeInstanceOf(Uint8Array);
});
+
+ it.each([8000, 9000])(
+ "accepts refreshed activity at %s without inferring a restore label",
+ async (head) => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(makeChangeset()));
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [{ from: 8000, to: 8000 }] }),
+ );
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ doc: Y.encodeStateAsUpdate(doc) }),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
+
+ await endpoints.restore!(fragment, SNAPSHOT_1);
+ expect(fetchSpy).toHaveBeenCalledTimes(4);
+
+ // The controller refreshes once: the result can be stale or contain
+ // someone else's concurrent edit. Neither proves this is our rollback.
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [{ from: head, to: head }] }),
+ );
+ const { current } = await endpoints.list();
+ expect(fetchSpy).toHaveBeenCalledTimes(5);
+ expect(current.createdAt).toBe(head);
+ expect(current.restoredFrom).toBeUndefined();
+ expect(versionEntries(doc)).toEqual([
+ { id: 8000, name: "Before restore" },
+ ]);
+ },
+ );
+
+ it("doesn't re-pin the pre-restore head when it's already addressable", async () => {
+ const doc = new Y.Doc();
+ const { endpoints, fragment } = makeCollabEndpoints(doc);
+
+ // The pre-restore head already carries an entry (a named version), so
+ // pinning it again would just duplicate it.
+ doc.get("__bn_versions").push([{ id: 8000, name: "Pre-restore" }]);
+
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(makeChangeset()));
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ activity: [{ from: 8000, to: 8000 }] }),
+ );
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponse({ doc: Y.encodeStateAsUpdate(doc) }),
+ );
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
+
+ await endpoints.restore!(fragment, SNAPSHOT_1);
+
+ expect(versionEntries(doc)).toEqual([{ id: 8000, name: "Pre-restore" }]);
+ });
});
// -------------------------------------------------------------------------
- // rename
+ // rename / remove
// -------------------------------------------------------------------------
describe("rename", () => {
- it("provides a rename endpoint", () => {
+ it("upserts the name onto the version's entry", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ await endpoints.rename!(SNAPSHOT_1, "New");
+
+ expect(versionEntries(doc)).toEqual([{ id: ENTRY_1.to, name: "New" }]);
+
+ await endpoints.rename!(SNAPSHOT_1, "Newer");
+
+ expect(versionEntries(doc)).toEqual([{ id: ENTRY_1.to, name: "Newer" }]);
+ });
+
+ it("applies an upsert as a single Yjs transaction", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "Old" }]);
+ let transactions = 0;
+ doc.on("afterTransaction", () => transactions++);
+
+ // Replace-with-same-id: a delete and a push, riding one transaction.
+ await endpoints.rename!(SNAPSHOT_1, "New");
+
+ expect(transactions).toBe(1);
+ });
+
+ it("throws when the version id isn't a YHub server timestamp", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ await expect(
+ endpoints.rename!({ id: "not-a-timestamp", createdAt: 0 }, "New"),
+ ).rejects.toThrow("not a YHub server timestamp");
+ // Nothing was written along the way.
+ expect(versionEntries(doc)).toEqual([]);
+ });
+
+ it("clears the name but keeps the rest of the entry", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc
+ .get("__bn_versions")
+ .push([{ id: ENTRY_1.to, name: "Old", restoredFrom: 42 }]);
+
+ await endpoints.rename!(SNAPSHOT_1, undefined);
+
+ expect(versionEntries(doc)).toEqual([
+ { id: ENTRY_1.to, restoredFrom: 42 },
+ ]);
+ });
+
+ it("drops the entry entirely when clearing leaves nothing behind", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "Old" }]);
+
+ await endpoints.rename!(SNAPSHOT_1, "");
+
+ expect(versionEntries(doc)).toEqual([]);
+ });
+
+ it("throws when there is no live collaboration document", async () => {
const endpoints = makeEndpoints();
- expect(typeof endpoints.rename).toBe("function");
+ await expect(endpoints.rename!(SNAPSHOT_1, "x")).rejects.toThrow(
+ "Assert failed",
+ );
});
+ });
- it("rename sets the name in the Y.Map", async () => {
+ describe("remove", () => {
+ it("deletes the version's entry", async () => {
const doc = new Y.Doc();
- const { endpoints, fragment } = makeCollabEndpoints(doc);
+ const { endpoints } = makeCollabEndpoints(doc);
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
- const snapshot = await endpoints.create!(fragment as any, {
- name: "Old",
- });
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "Old" }]);
+ doc.get("__bn_versions").push([{ id: ENTRY_2.to, name: "Keep" }]);
- await endpoints.rename!(snapshot, "New");
+ await endpoints.remove!(SNAPSHOT_1);
- const names = doc.get("__bn_version_names");
- expect(names.getAttr(snapshot.id as string)).toBe("New");
+ expect(versionEntries(doc)).toEqual([{ id: ENTRY_2.to, name: "Keep" }]);
});
- it("rename with no name clears the entry", async () => {
+ it("drops only the name, keeping the rest of the entry", async () => {
const doc = new Y.Doc();
- const { endpoints, fragment } = makeCollabEndpoints(doc);
+ const { endpoints } = makeCollabEndpoints(doc);
- fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
- const snapshot = await endpoints.create!(fragment as any, {
- name: "Old",
- });
+ doc
+ .get("__bn_versions")
+ .push([
+ { id: ENTRY_1.to, name: "Old", restoredFrom: 42, copyOf: "abc" },
+ ]);
+
+ await endpoints.remove!(SNAPSHOT_1);
- await endpoints.rename!(snapshot, undefined);
+ expect(versionEntries(doc)).toEqual([
+ { id: ENTRY_1.to, restoredFrom: 42, copyOf: "abc" },
+ ]);
+ });
- const names = doc.get("__bn_version_names");
- expect(names.hasAttr(snapshot.id as string)).toBe(false);
+ it("does nothing when the version has no stored entry", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ await endpoints.remove!(SNAPSHOT_1);
+
+ expect(versionEntries(doc)).toEqual([]);
+ });
+
+ it("throws when the version id isn't a YHub server timestamp", async () => {
+ const doc = new Y.Doc();
+ const { endpoints } = makeCollabEndpoints(doc);
+
+ doc.get("__bn_versions").push([{ id: ENTRY_1.to, name: "Old" }]);
+
+ await expect(
+ endpoints.remove!({ id: "not-a-timestamp", createdAt: 0 }),
+ ).rejects.toThrow("not a YHub server timestamp");
+ // Nothing was written or deleted along the way.
+ expect(versionEntries(doc)).toEqual([{ id: ENTRY_1.to, name: "Old" }]);
});
});
diff --git a/packages/core/src/y/versioning/index.ts b/packages/core/src/y/versioning/index.ts
index 739cdf36c7..4c77d238b5 100644
--- a/packages/core/src/y/versioning/index.ts
+++ b/packages/core/src/y/versioning/index.ts
@@ -1 +1,4 @@
-export * from "./yhub.js";
+export {
+ createYHubVersioningEndpoints,
+ type YHubVersioningOptions,
+} from "./yhub.js";
diff --git a/packages/core/src/y/versioning/yhub.ts b/packages/core/src/y/versioning/yhub.ts
index 56a93d71eb..f3558bcae7 100644
--- a/packages/core/src/y/versioning/yhub.ts
+++ b/packages/core/src/y/versioning/yhub.ts
@@ -1,637 +1,249 @@
import * as Y from "@y/y";
-import { decodeAny, encodeAny } from "lib0/buffer";
import {
- CURRENT_VERSION_ID,
- sortSnapshotsNewestFirst,
VersioningEndpointsFactory,
type VersioningEndpoints,
type VersionSnapshot,
} from "../../extensions/Versioning/index.js";
-import { uint32 } from "lib0/random";
-import { YCursorExtension } from "../extensions/YCursorPlugin.js";
import { YSyncExtension } from "../extensions/YSync.js";
-
-/**
- * Name of the root {@link Y.Type} map on the live collaboration doc that stores
- * a mutable `versionId -> name` mapping. Because YHub attributions are
- * immutable, version names that need to be editable (renamed) live here on the
- * Y.Doc instead of (or in addition to) the immutable `name` attribution.
- */
-const VERSION_NAMES_MAP = "__bn_version_names";
+import { collectFragmentIds } from "../utils.js";
+import { YHubVersionStore } from "./YHubVersionStore.js";
+import {
+ YHubClient,
+ type YHubActivityEntry,
+ type YHubClientOptions,
+ type YHubQueryParams,
+} from "./yhubClient.js";
/**
* Options for creating a YHub versioning endpoints instance.
+ * Restoration requires full-history read access (`GET /ydoc?gc=false`) and
+ * rollback permission. Naming uses the latest activity returned by YHub; it
+ * does not flush pending collaboration updates or bypass YHub's response cache.
*/
-export interface YHubVersioningOptions {
- /**
- * Base URL of the YHub API, including the API prefix
- * (e.g. `"https://yhub.example.com/api"`).
- * Must **not** include a trailing slash.
- */
- baseUrl: string;
-
- /** YHub organisation identifier. */
- org: string;
-
- /** Document identifier within the organisation. */
- docId: string;
-
- /**
- * Optional headers to include in every request (e.g. authentication tokens).
- */
- headers?: Record;
-
- /**
- * Maximum number of activity entries to fetch when listing versions.
- * @default 50
- */
- activityLimit?: number;
-
- /**
- * When set, forwarded as the `group` query param to the YHub activity API,
- * controlling whether adjacent edits are grouped into single entries.
- */
- group?: boolean;
-
- /**
- * Maximum gap (in ms) between edits for them to be grouped together.
- * Forwarded as the `groupMaxGap` query param.
- * @default 10000
- */
- groupMaxGap?: number;
-
- /**
- * Maximum total duration (in ms) a single group of edits may span.
- * When set, forwarded as the `groupMaxDuration` query param.
- */
- groupMaxDuration?: number;
-
- // TODO mergeUsers is not in standard yhub, but it exists in our fork.
- /**
- * When `true`, adjacent edits are grouped together even when made by
- * *different* users (their ids accumulate in the grouped entry's `by`).
- * When `false` (the default), only same-user adjacent edits are merged.
- * Forwarded as the `mergeUsers` query param.
- * @default false
- */
- mergeUsers?: boolean;
+export interface YHubVersioningOptions extends YHubClientOptions {
+ /** Activity query overrides, read fresh on each request. */
+ activityParams?: YHubQueryParams;
}
-/**
- * Shape of a single activity entry returned by the YHub
- * `GET /api/activity/v1/{org}/{docId}` endpoint (after `decodeAny`).
- */
-interface YHubActivityEntry {
- /** Start of the change window (unix-ms timestamp). */
- from: number;
- /** End of the change window (unix-ms timestamp). */
- to: number;
- /** Comma separated list of user-ids that matches the attribution */
- by?: string;
- /** Custom attribution key-value pairs (when `customAttributions=true`). */
- customAttributions?: Array<{ k: string; v: string }>;
+const ACTIVITY_PARAM_DEFAULTS: YHubQueryParams = {
+ order: "desc",
+ limit: 50,
+ groupMaxGap: 60 * 60 * 1000, // Start a version after an hour of inactivity.
+ groupMaxDuration: 12 * 60 * 60 * 1000, // Cap a version at twelve hours.
+ // Group a session across authors; YHub's default keeps each author separate.
+ groupByUser: false,
+ // Include custom attribution pairs as version metadata.
+ customAttributions: true,
+};
+
+/** Merge two metadata records, dropping the result when it's empty. */
+function mergeMetadata(
+ ...records: Array | undefined>
+): Record | undefined {
+ const merged = Object.assign({}, ...records) as Record;
+ return Object.keys(merged).length > 0 ? merged : undefined;
}
-/**
- * Shape returned by the YHub `GET /api/changeset/v1/{org}/{docId}` endpoint
- * (after `decodeAny`).
- */
-interface YHubChangeset {
- /** Full Y.Doc state at the `to` timestamp. */
- ydoc?: Uint8Array;
- /**
- * Encoded {@link Y.ContentMap} describing who authored each change in the
- * window and when. Present when the changeset is requested with
- * `attributions=true`.
- */
- attributions?: Uint8Array;
-}
-
-/** Shape returned by the YHub activity endpoint. */
-interface YHubActivityResponse {
- activity: YHubActivityEntry[];
-}
-
-/**
- * Whether an activity entry is a version marker (created with a `type:version`
- * custom attribution) as opposed to a plain edit.
- */
-function isVersionEntry(entry: YHubActivityEntry): boolean {
- return (
- entry.customAttributions?.some(
- (a) => a.k === "type" && a.v === "version",
- ) ?? false
- );
-}
-
-/**
- * Convert a YHub activity entry into a {@link VersionSnapshot}.
- *
- * Version markers (entries with a `type:version` custom attribution) map to
- * named snapshots: the `id` attribution becomes the snapshot identifier and the
- * `name` attribution its name. Any other (plain edit) entry maps to a
- * history-only snapshot with a synthetic `history--` id and no name.
- * In both cases the entry's `by` user-ids are passed through raw on
- * {@link VersionSnapshot.by} — resolving them to user info is the view layer's
- * job.
- *
- * The history id embeds the entry's `index` within the activity response
- * because YHub can emit multiple activity entries sharing the same `to`
- * timestamp (e.g. distinct same-`insertAt` patches that grouping did not merge),
- * and `to` alone would then produce colliding `history-` ids — duplicate
- * React keys in the sidebar. The `index` disambiguates them. The changeset
- * lookups (`getContent`/`getAttributions`/`restore`) key off
- * {@link VersionSnapshot.createdAt} (= `entry.to`), never the id, so embedding
- * the index in the id is safe.
- */
-function activityToSnapshot(
- entry: YHubActivityEntry,
- index: number,
-): VersionSnapshot | undefined {
- const by = entry.by
- ?.split(",")
- .map((s) => s.trim())
- .filter(Boolean);
- const byField = by && by.length > 0 ? by : undefined;
-
- if (isVersionEntry(entry)) {
- const id = entry.customAttributions?.find((a) => a.k === "id")?.v;
- if (id === undefined) {
- return undefined;
- }
- const attributionName = entry.customAttributions?.find(
- (a) => a.k === "name",
- )?.v;
- return {
- id,
- name: attributionName,
- createdAt: entry.to,
- updatedAt: entry.to,
- by: byField,
- };
- }
+// YHub always produces an array of author IDs.
+type YHubSnapshot = Omit & { by?: string[] };
+/** An activity window is identified by its end timestamp. */
+function activityToSnapshot(entry: YHubActivityEntry): YHubSnapshot {
return {
- id: `history-${entry.to}-${index}`,
+ id: String(entry.to),
createdAt: entry.to,
- updatedAt: entry.to,
- by: byField,
+ by: entry.by.length ? entry.by : undefined,
+ metadata: mergeMetadata(entry.customAttributions),
};
}
-async function yhubFetch(
- url: string,
- headers: Record,
- init?: RequestInit,
-): Promise {
- const res = await fetch(url, {
- ...init,
- headers: {
- ...headers,
- ...(init?.headers instanceof Headers
- ? Object.fromEntries(init.headers.entries())
- : Array.isArray(init?.headers)
- ? Object.fromEntries(init.headers)
- : init?.headers),
- },
- });
- if (!res.ok) {
+function timestampId(snapshot: VersionSnapshot): number {
+ const id = Number(snapshot.id);
+ if (!Number.isFinite(id)) {
throw new Error(
- `YHub request failed: ${res.status} ${res.statusText} (${url})`,
+ `Version id "${snapshot.id}" is not a YHub server timestamp.`,
);
}
- return res.arrayBuffer();
+ return id;
}
/**
- * Create a {@link VersioningEndpoints} implementation backed by the
- * [YHub](https://github.com/yjs/yhub) HTTP API.
- *
- * Versions are created by PATCHing the document with custom attributions
- * (`type:version` + an optional `name`). The `list` endpoint returns the full
- * activity timeline, mapping `type:version` markers to named versions and every
- * other entry to a history-only snapshot, so the sidebar can show both the
- * named versions and the complete edit history.
- *
- * A version's id lives in immutable YHub attributions (`type:version` + `id`),
- * so it is fixed at creation time. Version *names*, however, are stored in a
- * mutable `__bn_version_names` map on the live collaboration doc (see
- * {@link VERSION_NAMES_MAP}), so `rename` is supported and simply updates that
- * store.
- *
- * @example
- * ```ts
- * import { withCollaboration } from "@blocknote/core/y";
- * import { createYHubVersioningEndpoints } from "@blocknote/core/y";
- *
- * const editor = BlockNoteEditor.create(
- * withCollaboration({
- * collaboration: {
- * fragment,
- * user: { name: "Alice", color: "#ff0" },
- * provider,
- * versioningEndpoints: createYHubVersioningEndpoints({
- * baseUrl: "https://yhub.example.com/api",
- * org: "my-org",
- * docId: "my-doc",
- * }),
- * },
- * }),
- * );
- * ```
+ * Adapts YHub's activity timeline to versions. The newest activity is current;
+ * names and restore labels are stored separately in the collaboration document.
*/
export function createYHubVersioningEndpoints(
options: YHubVersioningOptions,
-): VersioningEndpointsFactory {
- const {
- baseUrl,
- org,
- docId,
- headers = {},
- activityLimit = 50,
- group,
- } = options;
-
- const activityUrl = `${baseUrl}/activity/v1/${org}/${docId}`;
- const changesetUrl = `${baseUrl}/changeset/v1/${org}/${docId}`;
- const rollbackUrl = `${baseUrl}/rollback/v1/${org}/${docId}`;
- const ydocUrl = `${baseUrl}/ydoc/v1/${org}/${docId}`;
+): VersioningEndpointsFactory {
+ const client = new YHubClient(options);
return (editor) => {
- /**
- * The mutable per-id version-name store on the live collaboration doc.
- *
- * Returns the root {@link VERSION_NAMES_MAP} map-typed {@link Y.Type}, which
- * uses `setAttr`/`getAttr` for keyed access (this Yjs fork has a single
- * unified `Y.Type` rather than a distinct `Y.Map`). `undefined` until the
- * live doc has been captured from a `create` call.
- */
- const getVersionNamesMap = (): Y.Type | undefined => {
- const fragment =
- editor.getExtension("ySync")?.fragment.doc;
- // `fragment` is undefined until the live doc has been captured (e.g. no
- // ySync extension attached yet); return undefined rather than throwing so
- // callers can gracefully fall back to the immutable name attribution.
- return fragment?.get(VERSION_NAMES_MAP);
- };
-
- /**
- * Build the synthetic "current version" snapshot, or `undefined` when the
- * live document matches the latest saved version (no edits since).
- *
- * Both lookups are made here, independently of the grouped `list()` request:
- *
- * - the newest activity entry of *any* kind (ungrouped, so its `to` is the
- * true last-edit time), and
- * - the newest **version marker** (via the `withCustomAttributions`
- * server-side filter).
- *
- * Deriving the marker time from `list()`'s grouped entries would be wrong:
- * with grouping (especially `mergeUsers`) the newest marker's group absorbs
- * the later unsaved edit, so the group's `to` equals the edit's `to` and the
- * comparison below can never fire. Fetching the marker unmerged avoids that.
- */
- const getCurrentVersionEntry = async (): Promise<
- VersionSnapshot | undefined
- > => {
- const latestParams = new URLSearchParams({
- order: "desc",
- limit: "1",
- customAttributions: "true",
- });
- const latestVersionParams = new URLSearchParams({
- order: "desc",
- limit: "1",
- customAttributions: "true",
- // Server-side filter to `type:version` markers only, so this ignores the
- // plain edits that would otherwise be the newest entries.
- withCustomAttributions: "type:version",
- });
-
- const [latestBuf, latestVersionBuf] = await Promise.all([
- yhubFetch(`${activityUrl}?${latestParams}`, headers),
- yhubFetch(`${activityUrl}?${latestVersionParams}`, headers),
- ]);
- const latestEdit = (
- decodeAny(new Uint8Array(latestBuf)) as YHubActivityResponse
- ).activity[0];
- const latestVersion = (
- decodeAny(new Uint8Array(latestVersionBuf)) as YHubActivityResponse
- ).activity[0];
-
- if (!latestEdit || latestEdit.to <= (latestVersion?.to ?? 0)) {
- return undefined;
- }
-
- // Build the synthetic entry directly rather than via `activityToSnapshot`,
- // whose `id` comes from a string-typed wire attribution — the current
- // entry's id is the `CURRENT_VERSION_ID` symbol, not a real version id.
- const by =
- latestEdit.by
- ?.split(",")
- .map((t) => t.trim())
- .filter(Boolean) ?? [];
- return {
- id: CURRENT_VERSION_ID,
- createdAt: latestEdit.to,
- updatedAt: latestEdit.to,
- by: by.length > 0 ? by : undefined,
- };
- };
-
- /**
- * PATCH the current document state to YHub, optionally with custom
- * attributions. Used both for creating named version markers and for
- * backing up the document before a restore.
- */
- const patchDoc = async (
- fragment: Y.Type,
- customAttributions: Array<{ k: string; v: any }>,
- by?: string,
- ) => {
- const doc = fragment.doc;
- if (!doc) {
- throw new Error(
- "Cannot patch document: the Y.Type is not attached to a Y.Doc.",
- );
- }
-
- // YHub only records custom attributions when they attach to NEW content
- // that survives its server-side diff. An update-less PATCH is rejected
- // (400 — "at least one of update or awareness must be present"), and even
- // if it weren't, there'd be no content for the attributions to ride on, so
- // no activity entry is created. YHub has no metadata-only marker path.
- //
- // So we introduce a tiny piece of novel content for the marker to attach
- // to: a single insert into a dedicated `__bn_version_markers` fragment that
- // the editor never renders. A fresh Y.Doc guarantees a clientID/content the
- // server has never seen, so the diff is non-empty and the attributions land
- // on it. The reconstructed document at this version's timestamp still
- // contains the full editor content — this marker only ever lives in the
- // throwaway fragment.
- const markerDoc = new Y.Doc();
- markerDoc.get("__bn_version_markers", "XmlFragment").insert(0, ["v"]);
- const update = Y.encodeStateAsUpdate(markerDoc);
-
- const body: Record = { update, customAttributions };
- if (by) {
- body.by = by;
- }
-
- await yhubFetch(ydocUrl, headers, {
- method: "PATCH",
- body: encodeAny(body) as BufferSource,
- });
- };
-
- /**
- * Create a named version marker for the current document state by PATCHing
- * it with `type:version` custom attributions.
- */
- const create: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["create"] = async (fragment, options) => {
- const id = String(uint32());
- const now = Date.now();
-
- if (options?.name) {
- getVersionNamesMap()?.setAttr(id, options.name);
- }
-
- const customAttributions: Array<{ k: string; v: string }> = [
- { k: "type", v: "version" },
- { k: "id", v: id },
- ];
- if (options?.name) {
- customAttributions.push({ k: "name", v: options.name });
- }
-
- const user = editor
- .getExtension("yCursor")
- ?.getUser();
- await patchDoc(fragment, customAttributions, user?.id);
-
- return {
- id,
- name: options?.name,
- createdAt: now,
- updatedAt: now,
- by: user?.id,
- };
- };
-
- /**
- * Reconstruct the full document state as it was at a given `to` timestamp.
- *
- * The changeset endpoint builds `ydoc` purely from the `to` timestamp
- * range — it ignores `withCustomAttributions` for doc reconstruction (that
- * filter only scopes the attribution overlay). So historical document state
- * can only be retrieved by timestamp, never by the version's `id`.
- */
- const getContentAt = async (to: number): Promise => {
- const params = new URLSearchParams({
- ydoc: "true",
- to: String(to),
- });
-
- const buf = await yhubFetch(`${changesetUrl}?${params}`, headers);
- const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset;
-
- if (!changeset.ydoc) {
- throw new Error(`YHub returned no document state at timestamp ${to}.`);
- }
-
- return Y.convertUpdateFormatV1ToV2(changeset.ydoc);
- };
-
- /**
- * Fetch the full document content for a saved version snapshot.
- *
- * The snapshot's `createdAt` is the activity entry's `to` timestamp (see
- * {@link activityToSnapshot}), which is exactly what the changeset API needs.
- */
- const getContent: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["getContent"] = async (snapshot) => {
- return getContentAt(snapshot.createdAt);
- };
-
- /**
- * Fetch the authorship attributions for the changes between two snapshots
- * (or from the start of the document when `compareTo` is omitted).
- *
- * Snapshots carry their `to` timestamp directly in `createdAt`, so no
- * activity lookup is needed to resolve the changeset window.
- */
- const getAttributions: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["getAttributions"] = async (snapshot, compareTo) => {
- const to = snapshot.createdAt;
- const from = compareTo !== undefined ? compareTo.createdAt : 0;
+ const versions = new YHubVersionStore(
+ () =>
+ editor.getExtension("ySync")?.fragment.doc as
+ | Y.Doc
+ | undefined,
+ );
+ let listedCurrent: YHubSnapshot | undefined;
- const params = new URLSearchParams({
- from: String(from),
- to: String(to),
- attributions: "true",
+ function fetchActivity(overrides?: YHubQueryParams) {
+ return client.getActivity({
+ ...ACTIVITY_PARAM_DEFAULTS,
+ ...options.activityParams,
+ ...overrides,
});
+ }
- const buf = await yhubFetch(`${changesetUrl}?${params}`, headers);
- const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset;
-
- if (!changeset.attributions) {
- throw new Error(
- `YHub returned no attributions for snapshot ${String(snapshot.id)}.`,
- );
- }
-
- return Y.decodeContentMap(changeset.attributions);
- };
-
- /**
- * Restore the document to a saved version: fetch the target version's
- * content and roll back everything after it.
- *
- * The snapshot's `createdAt` is the activity entry's `to` timestamp.
- */
- const restore: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["restore"] = async (_fragment, snapshot) => {
- const to = snapshot.createdAt;
- const snapshotContent = await getContentAt(to);
-
- await yhubFetch(rollbackUrl, headers, {
- method: "POST",
- body: encodeAny({ from: to }) as BufferSource,
- });
+ async function fetchNewestEntry() {
+ return (await fetchActivity({ limit: 1, order: "desc" }))[0];
+ }
- return snapshotContent;
- };
+ async function getContentAt(to: number) {
+ return Y.convertUpdateFormatV1ToV2(await client.getContent(to));
+ }
- /**
- * Rename a saved version by updating its entry in the mutable
- * {@link VERSION_NAMES_MAP} store on the live collaboration doc.
- *
- * The version's `id` remains fixed in its immutable YHub attributions —
- * only the editable name in the map is changed. Passing an empty or
- * `undefined` name clears the entry (falling back to the immutable `name`
- * attribution captured at creation time).
- */
- const rename: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["rename"] = async (snapshot, name) => {
- if (typeof snapshot.id !== "string") {
- // CURRENT_VERSION_ID (symbol) is not renameable.
- return;
- }
- const map = getVersionNamesMap();
- if (!map) {
- throw new Error(
- "Cannot rename version: no live collaboration document is available.",
+ return {
+ async list() {
+ const activity = await fetchActivity();
+
+ const rows = new Map();
+ for (const entry of activity) {
+ const existing = rows.get(entry.to);
+ const by = [...new Set([...(existing?.by ?? []), ...entry.by])];
+ rows.set(entry.to, {
+ ...activityToSnapshot(entry),
+ by: by.length ? by : undefined,
+ metadata: mergeMetadata(
+ existing?.metadata,
+ entry.customAttributions,
+ ),
+ });
+ }
+
+ // Stored labels can have newer timestamps, but only activity defines current.
+ const newestActivityTo = Math.max(...rows.keys());
+
+ for (const [id, entry] of versions.readEntries()) {
+ const { id: _id, name, restoredFrom, ...metadata } = entry;
+ const row = rows.get(id) ?? { id: String(id), createdAt: id };
+ rows.set(id, {
+ ...row,
+ name,
+ // The stored `to` is the restored version's whole identity here.
+ restoredFrom:
+ restoredFrom === undefined
+ ? undefined
+ : { id: String(restoredFrom), createdAt: restoredFrom },
+ metadata: mergeMetadata(row.metadata, metadata),
+ });
+ }
+
+ const now = Date.now();
+ const current = rows.get(newestActivityTo) ?? {
+ id: String(now),
+ createdAt: now,
+ };
+ rows.delete(Number(current.id));
+ listedCurrent = current;
+ return {
+ current,
+ snapshots: [...rows.values()].sort(
+ (a, b) => b.createdAt - a.createdAt,
+ ),
+ };
+ },
+
+ async create(_fragment, createOptions) {
+ // Fail before the fetch when there's nothing to write the name into.
+ versions.getArray();
+
+ let current = listedCurrent;
+ if (!current) {
+ const newest = await fetchNewestEntry();
+ if (!newest) {
+ throw new Error(
+ "Cannot name the current version: YHub has recorded no activity " +
+ "for this document yet.",
+ );
+ }
+ current = activityToSnapshot(newest);
+ }
+
+ // Saving unnamed preserves any existing name on the listed edit.
+ if (createOptions.name) {
+ versions.setName(timestampId(current), createOptions.name);
+ }
+ return {
+ ...current,
+ name: versions.readEntries().get(timestampId(current))?.name,
+ };
+ },
+
+ async getContent(snapshot) {
+ return getContentAt(snapshot.createdAt);
+ },
+
+ async getAttributions(target, compareTo) {
+ // Current previews include live edits beyond the last list response.
+ return Y.decodeContentMap(
+ await client.getAttributions(
+ compareTo?.createdAt ?? 0,
+ target.kind === "snapshot" ? target.snapshot.createdAt : undefined,
+ ),
);
- }
- if (name === undefined || name === "") {
- map.deleteAttr(snapshot.id);
- } else {
- map.setAttr(snapshot.id, name);
- }
- };
-
- /**
- * List the full version timeline (newest first), plus a synthetic
- * "current version" entry when the live document has unsaved edits.
- *
- * Returns the entire activity timeline: `type:version` markers are mapped
- * to named snapshots and every other entry to a history-only snapshot (see
- * {@link activityToSnapshot}), so the sidebar can offer both a "named
- * versions" and a full "history" view. Author user-ids are passed through
- * raw on {@link VersionSnapshot.by} — the view layer resolves them to user
- * info via the versioning extension's user store.
- */
- const list: VersioningEndpoints<
- Y.Type,
- Uint8Array,
- Y.ContentMap
- >["list"] = async () => {
- // Read the grouping knobs fresh from `options` so a caller mutating the
- // object it passed in reconfigures grouping on the next refresh (see the
- // note where these are deliberately left out of the destructure above).
- const groupMaxGap = options.groupMaxGap ?? 10000;
- const groupMaxDuration = options.groupMaxDuration;
- const mergeUsers = options.mergeUsers;
-
- const params = new URLSearchParams({
- order: "desc",
- limit: String(activityLimit),
- customAttributions: "true",
- });
- // Always send a concrete `groupMaxGap`. Sending
- // `String(undefined)` here would make the server `parseInt("undefined")`
- // to NaN, silently disabling grouping — which surfaces every same-`to`
- // attribution as its own history entry and produces duplicate React keys.
- params.set("groupMaxGap", String(groupMaxGap));
- if (group !== undefined) {
- params.set("group", String(group));
- }
- if (groupMaxDuration !== undefined) {
- params.set("groupMaxDuration", String(groupMaxDuration));
- }
- if (mergeUsers !== undefined) {
- params.set("mergeUsers", String(mergeUsers));
- }
-
- const buf = await yhubFetch(`${activityUrl}?${params}`, headers);
- const { activity: entries } = decodeAny(
- new Uint8Array(buf),
- ) as YHubActivityResponse;
-
- const snapshots = sortSnapshotsNewestFirst(
- entries
- .map((entry, i) => activityToSnapshot(entry, i))
- .filter((s): s is VersionSnapshot => s !== undefined)
- // Prefer the mutable per-id name from the live doc's
- // `__bn_version_names` store over the immutable `name` attribution,
- // so renames (which only mutate that store) are reflected here.
- .map((snapshot) => {
- const attributionName = snapshot.name;
- const mappedName =
- (typeof snapshot.id === "string"
- ? (getVersionNamesMap()?.getAttr(snapshot.id) as
- | string
- | undefined)
- : undefined) ?? attributionName;
- return { ...snapshot, name: mappedName };
+ },
+
+ async restore(fragment, snapshot) {
+ const to = snapshot.createdAt;
+ const [snapshotContent, before, document] = await Promise.all([
+ getContentAt(to),
+ fetchNewestEntry(),
+ // Retained history includes deleted subtrees missing from the live doc.
+ client.getDocument({ gc: false }),
+ ]);
+
+ // Restore only this editor's fragment, preserving other editors and metadata.
+ // A plain timestamp rollback would also roll back the stored version
+ // names (they live in the same doc), so scope the rollback to this
+ // fragment's Yjs ID ranges instead. This stays until YHub exposes a
+ // native versioning API.
+ const contentIds = collectFragmentIds(fragment, document);
+
+ await client.rollback({
+ // YHub includes the `from` millisecond. Keep the selected version's
+ // final edit, reverting only edits strictly after its timestamp.
+ from: to + 1,
+ contentIds: Y.encodeContentIds({
+ inserts: contentIds,
+ deletes: contentIds,
}),
- );
-
- // Surface a "current version" entry when the live document has edits
- // beyond the most recent saved version marker. `getCurrentVersionEntry`
- // makes its own unmerged lookups (see there), so it is unaffected by the
- // grouping/mergeUsers params used for the list above.
- //
- // This only re-evaluates when `list()` runs (sidebar open / refresh),
- // which matches how YHub versions load today.
- const currentEntry = await getCurrentVersionEntry();
- return currentEntry ? [currentEntry, ...snapshots] : snapshots;
- };
-
- return {
- list,
- create,
- getContent,
- getAttributions,
- restore,
- rename,
- };
+ });
+
+ // Keep the last observed head reachable even if grouping absorbs the rollback.
+ if (before && !versions.readEntries().has(before.to)) {
+ versions.upsertEntry({
+ id: before.to,
+ name: editor.dictionary.versioning.before_restore,
+ });
+ }
+ // A newer activity entry could be
+ // another user's edit, so it cannot reliably identify this rollback.
+
+ return snapshotContent;
+ },
+
+ async rename(snapshot, name) {
+ versions.setName(timestampId(snapshot), name);
+ },
+
+ async remove(snapshot) {
+ // Deleting a stored version clears its name, preserving restore and
+ // application metadata. Automatic versions have nothing to remove.
+ const id = timestampId(snapshot);
+ if (versions.readEntries().has(id)) {
+ versions.setName(id, undefined);
+ }
+ },
+ } satisfies VersioningEndpoints;
};
}
diff --git a/packages/core/src/y/versioning/yhubClient.ts b/packages/core/src/y/versioning/yhubClient.ts
new file mode 100644
index 0000000000..2232a7e14a
--- /dev/null
+++ b/packages/core/src/y/versioning/yhubClient.ts
@@ -0,0 +1,145 @@
+import { decodeAny, encodeAny } from "lib0/buffer";
+
+export interface YHubClientOptions {
+ /** API base URL, including the API prefix, without a trailing slash. */
+ baseUrl: string;
+ /** Organisation identifier. */
+ org: string;
+ /** Document identifier within the organisation. */
+ docId: string;
+ /** Headers included in every request, e.g. authentication tokens. */
+ headers?: Record