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