From 7bc008c2cae07a65b13876ff6e218b6622aadec1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 18 Dec 2025 10:14:09 +0100 Subject: [PATCH 01/10] Added local storage versioning demo --- .../09-versioning/.bnexample.json | 10 + .../07-collaboration/09-versioning/README.md | 9 + .../07-collaboration/09-versioning/index.html | 14 + .../07-collaboration/09-versioning/main.tsx | 11 + .../09-versioning/package.json | 33 ++ .../09-versioning/src/App.tsx | 57 ++++ .../09-versioning/src/style.css | 106 +++++++ .../09-versioning/tsconfig.json | 36 +++ .../09-versioning/vite.config.ts | 32 ++ .../src/extensions/Versioning/Versioning.ts | 284 ++++++++++++++++++ .../Versioning/localStorageEndpoints.ts | 98 ++++++ packages/core/src/extensions/index.ts | 2 + .../components/Versioning/CurrentSnapshot.tsx | 43 +++ .../src/components/Versioning/Snapshot.tsx | 64 ++++ .../Versioning/VersioningSidebar.tsx | 18 ++ .../src/components/Versioning/dateToString.ts | 9 + packages/react/src/index.ts | 2 + playground/src/examples.gen.tsx | 25 ++ 18 files changed, 853 insertions(+) create mode 100644 examples/07-collaboration/09-versioning/.bnexample.json create mode 100644 examples/07-collaboration/09-versioning/README.md create mode 100644 examples/07-collaboration/09-versioning/index.html create mode 100644 examples/07-collaboration/09-versioning/main.tsx create mode 100644 examples/07-collaboration/09-versioning/package.json create mode 100644 examples/07-collaboration/09-versioning/src/App.tsx create mode 100644 examples/07-collaboration/09-versioning/src/style.css create mode 100644 examples/07-collaboration/09-versioning/tsconfig.json create mode 100644 examples/07-collaboration/09-versioning/vite.config.ts create mode 100644 packages/core/src/extensions/Versioning/Versioning.ts create mode 100644 packages/core/src/extensions/Versioning/localStorageEndpoints.ts create mode 100644 packages/react/src/components/Versioning/CurrentSnapshot.tsx create mode 100644 packages/react/src/components/Versioning/Snapshot.tsx create mode 100644 packages/react/src/components/Versioning/VersioningSidebar.tsx create mode 100644 packages/react/src/components/Versioning/dateToString.ts diff --git a/examples/07-collaboration/09-versioning/.bnexample.json b/examples/07-collaboration/09-versioning/.bnexample.json new file mode 100644 index 0000000000..851eb5071c --- /dev/null +++ b/examples/07-collaboration/09-versioning/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": true, + "author": "matthewlipski", + "tags": ["Advanced", "Development", "Collaboration"], + "dependencies": { + "y-partykit": "^0.0.25", + "yjs": "^13.6.27" + } +} diff --git a/examples/07-collaboration/09-versioning/README.md b/examples/07-collaboration/09-versioning/README.md new file mode 100644 index 0000000000..b7aad68c90 --- /dev/null +++ b/examples/07-collaboration/09-versioning/README.md @@ -0,0 +1,9 @@ +# Collaborative Editing with Versioning + +In this example, we can save snapshots of the document that we can later preview and revert to. + +**Try it out:** Press the save button in the versioning sidebar to save a document snapshot! + +**Relevant Docs:** + +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/07-collaboration/09-versioning/index.html b/examples/07-collaboration/09-versioning/index.html new file mode 100644 index 0000000000..d05d50dbe6 --- /dev/null +++ b/examples/07-collaboration/09-versioning/index.html @@ -0,0 +1,14 @@ + + + + + Collaborative Editing with Versioning + + + +
+ + + diff --git a/examples/07-collaboration/09-versioning/main.tsx b/examples/07-collaboration/09-versioning/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/07-collaboration/09-versioning/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/09-versioning/package.json b/examples/07-collaboration/09-versioning/package.json new file mode 100644 index 0000000000..bae34d14bd --- /dev/null +++ b/examples/07-collaboration/09-versioning/package.json @@ -0,0 +1,33 @@ +{ + "name": "@blocknote/example-collaboration-versioning", + "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": "^8.3.4", + "@mantine/hooks": "^8.3.4", + "@mantine/utils": "^6.0.22", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "y-partykit": "^0.0.25", + "yjs": "^13.6.27" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.7.0", + "vite": "^5.4.20" + } +} \ No newline at end of file diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx new file mode 100644 index 0000000000..6669a5bf55 --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -0,0 +1,57 @@ +import "@blocknote/core/fonts/inter.css"; +import { + localStorageEndpoints, + VersioningExtension, +} from "@blocknote/core/extensions"; +import { + BlockNoteViewEditor, + useCreateBlockNote, + VersioningSidebar, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import "./style.css"; +// import YPartyKitProvider from "y-partykit/provider"; +import * as Y from "yjs"; + +const doc = new Y.Doc(); + +export default function App() { + const editor = useCreateBlockNote({ + collaboration: { + // Where to store BlockNote data in the Y.Doc: + fragment: doc.getXmlFragment(), + // Information (name and color) for this user: + user: { + name: "My Username", + color: "#ff0000", + }, + }, + extensions: [ + VersioningExtension({ + endpoints: localStorageEndpoints, + fragment: doc.getXmlFragment(), + }), + ], + }); + + // Renders the editor instance. + return ( + console.log(doc.getXmlFragment().toJSON())} + > +
+

Editor

+ +
+
+

Version History

+ +
+
+ ); +} diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css new file mode 100644 index 0000000000..283cc13697 --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -0,0 +1,106 @@ +.version-history-main-container { + background-color: var(--bn-colors-disabled-background); + display: flex; + gap: 10px; + height: 100%; + max-width: none; + padding: 10px; + width: 100%; +} + +.version-history-main-container .editor-section, +.version-history-section { + border-radius: var(--bn-border-radius-large); + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; + max-height: 100%; + min-width: 350px; + width: 0; +} + +.version-history-main-container .editor-section h1, +.version-history-section h1 { + color: var(--bn-colors-menu-text); + margin: 0; + font-size: 32px; +} + +.version-history-main-container .bn-editor, +.bn-versioning-sidebar { + border-radius: var(--bn-border-radius-medium); + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + overflow: auto; +} + +.version-history-main-container .editor-section { + max-width: 700px; +} + +.bn-versioning-sidebar { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bn-snapshot { + background-color: var(--bn-colors-menu-background); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + color: var(--bn-colors-menu-text); + cursor: pointer; + flex-direction: column; + gap: 16px; + display: flex; + overflow: visible; + padding: 16px 32px; + width: 100%; +} + +.bn-snapshot-name { + background: transparent; + border: none; + color: var(--bn-colors-menu-text); + font-size: 16px; + font-weight: 600; + padding: 0; + width: 100%; +} + +.bn-snapshot-body { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 4px; +} + +.bn-snapshot-button { + /* align-self: flex-end; */ + background-color: #c2dcf8; + border: none; + border-radius: 4px; + color: var(--bn-colors-menu-text); + cursor: pointer; + font-size: 12px; + font-weight: 600; + padding: 0 8px; + width: fit-content; +} + +.dark .bn-snapshot-button { + background-color: #226fb7; +} + +.bn-versioning-sidebar .bn-snapshot.selected { + background-color: #f5f9fd; + border: 2px solid #c2dcf8; +} + +.dark .bn-versioning-sidebar .bn-snapshot.selected { + background-color: #20242a; + border: 2px solid #23405b; +} diff --git a/examples/07-collaboration/09-versioning/tsconfig.json b/examples/07-collaboration/09-versioning/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/07-collaboration/09-versioning/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/07-collaboration/09-versioning/vite.config.ts b/examples/07-collaboration/09-versioning/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/07-collaboration/09-versioning/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // 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), + }, +})); diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts new file mode 100644 index 0000000000..0c13176054 --- /dev/null +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -0,0 +1,284 @@ +import * as Y from "yjs"; + +import { + createExtension, + createStore, + ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; + +export interface VersionSnapshot { + /** + * The unique identifier for the snapshot. + */ + id: string; + /** + * The name of the snapshot. + */ + name?: string; + /** + * The timestamp when the snapshot was created (unix timestamp). + */ + createdAt: number; + /** + * The timestamp when the snapshot was last updated (unix timestamp). + */ + updatedAt: number; + /** + * If defined, indicates that the snapshot was created by reverting to a + * previous snapshot with the given ID. + */ + revertedSnapshotId?: string; + /** + * Additional metadata about the snapshot. + */ + meta: { + /** + * The user IDs associated with the snapshot. + */ + userIds?: string[]; + /** + * The content of the snapshot. + */ + contents?: Uint8Array; + selected?: boolean; + /** + * Additional metadata about the snapshot. + */ + [key: string]: unknown; + }; +} + +export interface VersioningEndpoints { + /** + * List all created snapshots for this document. + */ + listSnapshots: () => Promise; + /** + * Create a new snapshot for this document with the current content. + */ + createSnapshot: ( + fragment: Y.XmlFragment, + /** + * The optional name for this snapshot. + */ + name?: string, + ) => Promise; + /** + * Restore the current document to the provided snapshot ID. This should also + * append a new snapshot to the list with the reverted changes, and may + * include additional actions like appending a backup snapshot with the + * document content, just before reverting. + * + * @note if not provided, the UI will not allow the user to restore a + * snapshot. + * @returns the binary contents of the `Y.Doc` of the snapshot. + */ + restoreSnapshot?: ( + fragment: Y.XmlFragment, + id: string, + ) => Promise; + /** + * Fetch the contents of a snapshot. This is useful for previewing a + * snapshot before choosing to revert it. + * + * @returns the binary contents of the `Y.Doc` of the snapshot. + */ + fetchSnapshotContent: ( + /** + * The id of the snapshot to fetch the contents of. + */ + id: string, + ) => Promise; + /** + * Update the name of a snapshot. + * + * @note if not provided, the UI will not allow the user to update the name + */ + updateSnapshotName?: (id: string, name: string) => Promise; +} + +export const VersioningExtension = createExtension( + ({ + options: { endpoints, fragment }, + }: ExtensionOptions<{ + /** + * There are different endpoints that need to be provided to implement the versioning API. + */ + endpoints: VersioningEndpoints; + fragment: Y.XmlFragment; + }>) => { + const store = createStore<{ + snapshots: VersionSnapshot[]; + selectedSnapshotId?: string; + }>({ + snapshots: [], + selectedSnapshotId: undefined, + }); + + const updateSnapshots = async () => { + const snapshots = await endpoints.listSnapshots(); + store.setState((state) => ({ + ...state, + snapshots, + })); + }; + const updateSnapshotsSync = () => { + updateSnapshots(); + }; + + return { + key: "versioning", + store, + mount: () => updateSnapshotsSync(), + // TODO I'd probably have: + // canRestoreSnapshot: () => boolean; + // canUpdateSnapshotName: () => boolean; + listSnapshots: async (): Promise => { + await updateSnapshots(); + + return store.state.snapshots; + }, + createSnapshot: async (name?: string): Promise => { + await endpoints.createSnapshot(fragment, name); + await updateSnapshots(); + + return store.state.snapshots[0]; + }, + restoreSnapshot: endpoints.restoreSnapshot + ? async (id: string): Promise => { + const snapshotContent = await endpoints.restoreSnapshot!( + fragment, + id, + ); + await updateSnapshots(); + + return snapshotContent; + } + : undefined, + fetchSnapshotContent: async (id: string): Promise => { + const storeSnapshot = store.state.snapshots.find( + (snapshot) => snapshot.id === id, + ); + if (storeSnapshot && storeSnapshot.meta.contents !== undefined) { + return storeSnapshot.meta.contents; + } + + const snapshotContent = await endpoints.fetchSnapshotContent(id); + await updateSnapshots(); + + return snapshotContent; + }, + updateSnapshotName: endpoints.updateSnapshotName + ? async (id: string, name: string): Promise => { + await endpoints.updateSnapshotName!(id, name); + await updateSnapshots(); + } + : undefined, + + selectSnapshot: (id: string | undefined) => { + store.setState((state) => ({ + ...state, + selectedSnapshotId: id, + })); + }, + } as const; + }, +); + +// /** +// * Here is a mock implementation of the VersionAPI for the demo +// */ +// export const MockVersionAPI = Object.assign( +// { +// listSnapshots: async () => { +// await new Promise((resolve) => +// setTimeout(resolve, 600 + Math.random() * 1000), +// ); +// return JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// ) as VersionSnapshot[]; +// }, +// createSnapshot: async (name, fragment) => { +// await new Promise((resolve) => +// setTimeout(resolve, 600 + Math.random() * 1000), +// ); +// const snapshot = { +// id: v4(), +// name, +// createdAt: Date.now(), +// updatedAt: Date.now(), +// meta: { +// userIds: ["User"], +// // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome +// contents: Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), +// }, +// } satisfies VersionSnapshot; +// localStorage.setItem( +// "versions", +// JSON.stringify([ +// snapshot, +// ...(JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// ) as VersionSnapshot[]), +// ]), +// ); +// return Promise.resolve(snapshot); +// }, +// fetchSnapshotContent: async (id: string) => { +// await new Promise((resolve) => +// setTimeout(resolve, 600 + Math.random() * 1000), +// ); +// const snapshot = ( +// JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// ) as VersionSnapshot[] +// ).find((snapshot) => snapshot.id === id); +// if (snapshot === undefined) { +// throw new Error(`Document snapshot ${id} could not be found.`); +// } +// const binaryString = atob(snapshot.meta.contents as string); +// const uint8Array = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); +// return Promise.resolve(uint8Array); +// }, +// restoreSnapshot: async (id: string) => { +// await new Promise((resolve) => +// setTimeout(resolve, 600 + Math.random() * 1000), +// ); +// const versions = JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// ) as VersionSnapshot[]; +// const snapshotIndex = versions.findIndex( +// (snapshot: VersionSnapshot) => snapshot.id === id, +// ); +// if (snapshotIndex === -1) { +// throw new Error(`Document snapshot ${id} could not be found.`); +// } +// const snapshot = versions[snapshotIndex].meta.contents as string; +// const binaryString = atob(snapshot); +// const uint8Array = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); +// return Promise.resolve(uint8Array); +// }, +// updateSnapshotName: async (id: string, name: string) => { +// await new Promise((resolve) => +// setTimeout(resolve, 600 + Math.random() * 1000), +// ); +// const snapshot = JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// ).find((snapshot: VersionSnapshot) => snapshot.id === id); +// if (snapshot === undefined) { +// throw new Error(`Document snapshot ${id} could not be found.`); +// } +// snapshot.name = name; +// localStorage.setItem("versions", JSON.stringify(snapshot)); +// return Promise.resolve(); +// }, +// } satisfies VersioningEndpoints, +// { +// // This will load the initial snapshot from the localStorage for the demo +// getInitialSnapshot: () => { +// return JSON.parse( +// localStorage.getItem("versions") ?? "[]", +// )[0] as VersionSnapshot; +// }, +// }, +// ); diff --git a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts new file mode 100644 index 0000000000..11b6cad878 --- /dev/null +++ b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts @@ -0,0 +1,98 @@ +import { v4 } from "uuid"; +import * as Y from "yjs"; + +import { VersioningEndpoints, VersionSnapshot } from "./Versioning.js"; + +const listSnapshots: VersioningEndpoints["listSnapshots"] = async () => + JSON.parse(localStorage.getItem("snapshots") ?? "[]") as VersionSnapshot[]; + +const createSnapshot = async ( + fragment: Y.XmlFragment, + name?: string, + revertedSnapshotId?: string, +): Promise => { + const snapshot = { + id: v4(), + name, + createdAt: Date.now(), + updatedAt: Date.now(), + revertedSnapshotId, + meta: { + userIds: ["User1"], + // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome + contents: Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), + }, + } satisfies VersionSnapshot; + + localStorage.setItem( + "snapshots", + JSON.stringify([snapshot, ...(await listSnapshots())]), + ); + + return Promise.resolve(snapshot); +}; + +const fetchSnapshotContent: VersioningEndpoints["fetchSnapshotContent"] = + async (id) => { + const snapshots = await listSnapshots(); + + const snapshot = snapshots.find( + (snapshot: VersionSnapshot) => snapshot.id === id, + ); + if (snapshot === undefined) { + throw new Error(`Document snapshot ${id} could not be found.`); + } + if (!("contents" in snapshot.meta)) { + throw new Error(`Document snapshot ${id} doesn't contain content.`); + } + if (typeof snapshot.meta.contents !== "string") { + throw new Error(`Document snapshot ${id} contains invalid content.`); + } + + return Promise.resolve( + Uint8Array.from(atob(snapshot.meta.contents), (c) => c.charCodeAt(0)), + ); + }; + +const restoreSnapshot: VersioningEndpoints["restoreSnapshot"] = async ( + fragment, + id, +) => { + await createSnapshot(fragment, "Backup"); + + Y.mergeUpdatesV2([await fetchSnapshotContent(id)]); + + await createSnapshot(fragment, "Restored Snapshot", id); + + return Promise.resolve( + // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome + Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), + ); +}; + +const updateSnapshotName: VersioningEndpoints["updateSnapshotName"] = async ( + id, + name, +) => { + const snapshots = await listSnapshots(); + + const snapshot = snapshots.find( + (snapshot: VersionSnapshot) => snapshot.id === id, + ); + if (snapshot === undefined) { + throw new Error(`Document snapshot ${id} could not be found.`); + } + + snapshot.name = name; + localStorage.setItem("snapshots", JSON.stringify(snapshots)); + + return Promise.resolve(); +}; + +export const localStorageEndpoints: VersioningEndpoints = { + listSnapshots, + createSnapshot, + fetchSnapshotContent, + restoreSnapshot, + updateSnapshotName, +}; diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index 210a95222c..e4b3cb3c00 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -22,3 +22,5 @@ export * from "./SuggestionMenu/DefaultSuggestionItem.js"; export * from "./SuggestionMenu/DefaultGridSuggestionItem.js"; export * from "./TableHandles/TableHandles.js"; export * from "./TrailingNode/TrailingNode.js"; +export * from "./Versioning/Versioning.js"; +export * from "./Versioning/localStorageEndpoints.js"; diff --git a/packages/react/src/components/Versioning/CurrentSnapshot.tsx b/packages/react/src/components/Versioning/CurrentSnapshot.tsx new file mode 100644 index 0000000000..61ebf17a97 --- /dev/null +++ b/packages/react/src/components/Versioning/CurrentSnapshot.tsx @@ -0,0 +1,43 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { useState } from "react"; + +import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; + +export const CurrentSnapshot = () => { + const { createSnapshot, selectSnapshot } = useExtension(VersioningExtension); + const selected = useExtensionState(VersioningExtension, { + selector: (state) => state.selectedSnapshotId === undefined, + }); + + const [snapshotName, setSnapshotName] = useState("Current Version"); + + return ( +
selectSnapshot(undefined)} + > +
+ setSnapshotName(event.target.value)} + /> + {snapshotName !== "Current Version" && ( +
Current Version
+ )} +
+ +
+ ); +}; diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx new file mode 100644 index 0000000000..05f24d5ec6 --- /dev/null +++ b/packages/react/src/components/Versioning/Snapshot.tsx @@ -0,0 +1,64 @@ +import { + VersioningExtension, + VersionSnapshot, +} from "@blocknote/core/extensions"; + +import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { dateToString } from "./dateToString.js"; + +export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { + const { restoreSnapshot, updateSnapshotName, selectSnapshot } = + useExtension(VersioningExtension); + const selected = useExtensionState(VersioningExtension, { + selector: (state) => state.selectedSnapshotId === snapshot.id, + }); + const revertedSnapshot = useExtensionState(VersioningExtension, { + selector: (state) => + snapshot?.revertedSnapshotId !== undefined + ? state.snapshots.find( + (snap) => snap.id === snapshot.revertedSnapshotId, + ) + : undefined, + }); + + const dateString = dateToString(new Date(snapshot?.createdAt || 0)); + + if (snapshot === undefined) { + return null; + } + + return ( +
selectSnapshot(snapshot.id)} + > +
+ { + updateSnapshotName?.(snapshot.id, event.target.value); + }} + /> + {snapshot.name && snapshot.name !== dateString && ( +
{dateString}
+ )} + {revertedSnapshot && ( +
{`Restored from ${dateToString(new Date(revertedSnapshot.createdAt))}`}
+ )} + {/* TODO: Fetch user name */} + {snapshot.meta.userIds !== undefined && + snapshot.meta.userIds.length > 0 && ( +
{`Edited by ${snapshot.meta.userIds.join(", ")}`}
+ )} +
+ +
+ ); +}; diff --git a/packages/react/src/components/Versioning/VersioningSidebar.tsx b/packages/react/src/components/Versioning/VersioningSidebar.tsx new file mode 100644 index 0000000000..8e1af95b4e --- /dev/null +++ b/packages/react/src/components/Versioning/VersioningSidebar.tsx @@ -0,0 +1,18 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; + +import { useExtensionState } from "../../hooks/useExtension.js"; +import { CurrentSnapshot } from "./CurrentSnapshot.js"; +import { Snapshot } from "./Snapshot.js"; + +export const VersioningSidebar = () => { + const { snapshots } = useExtensionState(VersioningExtension); + + return ( +
+ + {snapshots.map((snapshot, index) => { + return ; + })} +
+ ); +}; diff --git a/packages/react/src/components/Versioning/dateToString.ts b/packages/react/src/components/Versioning/dateToString.ts new file mode 100644 index 0000000000..feb0e6048d --- /dev/null +++ b/packages/react/src/components/Versioning/dateToString.ts @@ -0,0 +1,9 @@ +export const dateToString = (date: Date) => + `${date.toLocaleDateString(undefined, { + day: "numeric", + month: "long", + year: "numeric", + })}, ${date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + })}`; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6b9a7d2697..ce36da9cdb 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -114,6 +114,8 @@ export * from "./components/Comments/ThreadsSidebar.js"; export * from "./components/Comments/useThreads.js"; export * from "./components/Comments/useUsers.js"; +export * from "./components/Versioning/VersioningSidebar.js"; + export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 67236495e5..9b175a391f 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1607,6 +1607,31 @@ "slug": "collaboration" }, "readme": "In this example, we can fork a document and edit it independently of other collaborators. Then, we can choose to merge the changes back into the original document, or discard the changes.\n\n**Try it out:** Open this page in a new browser tab or window to see it in action!\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)" + }, + { + "projectSlug": "versioning", + "fullSlug": "collaboration/versioning", + "pathFromRoot": "examples/07-collaboration/09-versioning", + "config": { + "playground": true, + "docs": true, + "author": "matthewlipski", + "tags": [ + "Advanced", + "Development", + "Collaboration" + ], + "dependencies": { + "y-partykit": "^0.0.25", + "yjs": "^13.6.27" + } as any + }, + "title": "Collaborative Editing with Versioning", + "group": { + "pathFromRoot": "examples/07-collaboration", + "slug": "collaboration" + }, + "readme": "In this example, we can save snapshots of the document that we can later preview and revert to. \n\n**Try it out:** Press the save button in the versioning sidebar to save a document snapshot!\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)" } ] }, From 326d52f3e462b6328705fe91ba15dd6a100c5fad Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 18 Dec 2025 11:01:42 +0100 Subject: [PATCH 02/10] feat: implement snapshot restoration --- .../src/extensions/Collaboration/ForkYDoc.ts | 2 +- .../src/extensions/Versioning/Versioning.ts | 60 ++++++++++++++++--- .../Versioning/localStorageEndpoints.ts | 20 ++++--- .../src/components/Versioning/Snapshot.tsx | 15 +++-- .../Versioning/VersioningSidebar.tsx | 4 +- pnpm-lock.yaml | 52 ++++++++++++++++ 6 files changed, 127 insertions(+), 26 deletions(-) diff --git a/packages/core/src/extensions/Collaboration/ForkYDoc.ts b/packages/core/src/extensions/Collaboration/ForkYDoc.ts index 84c714f1d3..6f7962afdd 100644 --- a/packages/core/src/extensions/Collaboration/ForkYDoc.ts +++ b/packages/core/src/extensions/Collaboration/ForkYDoc.ts @@ -13,7 +13,7 @@ import { YUndoExtension } from "./YUndo.js"; /** * To find a fragment in another ydoc, we need to search for it. */ -function findTypeInOtherYdoc>( +export function findTypeInOtherYdoc>( ytype: T, otherYdoc: Y.Doc, ): T { diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index 0c13176054..5b194b4cdc 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -5,6 +5,11 @@ import { createStore, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; +import { + findTypeInOtherYdoc, + ForkYDocExtension, +} from "../Collaboration/ForkYDoc.js"; +import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; export interface VersionSnapshot { /** @@ -23,11 +28,6 @@ export interface VersionSnapshot { * The timestamp when the snapshot was last updated (unix timestamp). */ updatedAt: number; - /** - * If defined, indicates that the snapshot was created by reverting to a - * previous snapshot with the given ID. - */ - revertedSnapshotId?: string; /** * Additional metadata about the snapshot. */ @@ -37,14 +37,20 @@ export interface VersionSnapshot { */ userIds?: string[]; /** - * The content of the snapshot. + * The ID of the previous snapshot that this snapshot was restored from. */ - contents?: Uint8Array; - selected?: boolean; + restoredFromSnapshotId?: string; /** * Additional metadata about the snapshot. */ [key: string]: unknown; + + // TODO this should not be exposed to the user (make it internal only) + /** + * The content of the snapshot. + */ + contents?: Uint8Array; + selected?: boolean; }; } @@ -62,6 +68,10 @@ export interface VersioningEndpoints { * The optional name for this snapshot. */ name?: string, + /** + * The ID of the previous snapshot that this snapshot was restored from. + */ + restoredFromSnapshotId?: string, ) => Promise; /** * Restore the current document to the provided snapshot ID. This should also @@ -99,6 +109,7 @@ export interface VersioningEndpoints { export const VersioningExtension = createExtension( ({ + editor, options: { endpoints, fragment }, }: ExtensionOptions<{ /** @@ -115,6 +126,23 @@ export const VersioningExtension = createExtension( selectedSnapshotId: undefined, }); + const applySnapshot = (snapshotContent: Uint8Array) => { + const yDoc = new Y.Doc(); + Y.applyUpdateV2(yDoc, snapshotContent); + + // Find the fragment within the newly restored document to then apply + const restoreFragment = findTypeInOtherYdoc(fragment, yDoc); + + const pmDoc = yXmlFragmentToProseMirrorRootNode( + restoreFragment, + editor.prosemirrorState.schema, + ); + + editor.transact((tr) => { + tr.replace(0, tr.doc.content.size - 2, pmDoc.slice(0)); + }); + }; + const updateSnapshots = async () => { const snapshots = await endpoints.listSnapshots(); store.setState((state) => ({ @@ -150,6 +178,7 @@ export const VersioningExtension = createExtension( fragment, id, ); + applySnapshot(snapshotContent); await updateSnapshots(); return snapshotContent; @@ -175,11 +204,24 @@ export const VersioningExtension = createExtension( } : undefined, - selectSnapshot: (id: string | undefined) => { + selectSnapshot: async (id: string | undefined) => { store.setState((state) => ({ ...state, selectedSnapshotId: id, })); + + if (id === undefined) { + editor.isEditable = true; + // when we go back to the original document, just revert changes `.merge({ keepChanges: false })` + editor.getExtension(ForkYDocExtension)!.merge({ keepChanges: false }); + return; + } + editor.getExtension(ForkYDocExtension)!.fork(); + editor.isEditable = false; + const snapshotContent = await endpoints.fetchSnapshotContent(id); + + // replace editor contents with the snapshot contents (affecting the forked document not the original) + applySnapshot(snapshotContent); }, } as const; }, diff --git a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts index 11b6cad878..b1cac42bf0 100644 --- a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts +++ b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts @@ -9,15 +9,15 @@ const listSnapshots: VersioningEndpoints["listSnapshots"] = async () => const createSnapshot = async ( fragment: Y.XmlFragment, name?: string, - revertedSnapshotId?: string, + restoredFromSnapshotId?: string, ): Promise => { const snapshot = { id: v4(), name, createdAt: Date.now(), updatedAt: Date.now(), - revertedSnapshotId, meta: { + restoredFromSnapshotId, userIds: ["User1"], // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome contents: Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), @@ -58,16 +58,20 @@ const restoreSnapshot: VersioningEndpoints["restoreSnapshot"] = async ( fragment, id, ) => { + // take a snapshot of the current document await createSnapshot(fragment, "Backup"); - Y.mergeUpdatesV2([await fetchSnapshotContent(id)]); + // hydrates the version document from it's contents, into a new Y.Doc + const snapshotContent = await fetchSnapshotContent(id); + const yDoc = new Y.Doc(); + Y.applyUpdateV2(yDoc, snapshotContent); - await createSnapshot(fragment, "Restored Snapshot", id); + // create a new snapshot from that, to store it back in the list + // Don't mind that the xmlFragment is not the right one, we just snapshot the whole doc anyway + await createSnapshot(yDoc.getXmlFragment(), "Restored Snapshot", id); - return Promise.resolve( - // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome - Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), - ); + // return what the new state should be + return snapshotContent; }; const updateSnapshotName: VersioningEndpoints["updateSnapshotName"] = async ( diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx index 05f24d5ec6..07c9094a22 100644 --- a/packages/react/src/components/Versioning/Snapshot.tsx +++ b/packages/react/src/components/Versioning/Snapshot.tsx @@ -5,6 +5,7 @@ import { import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { dateToString } from "./dateToString.js"; +import { useState } from "react"; export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { const { restoreSnapshot, updateSnapshotName, selectSnapshot } = @@ -14,14 +15,17 @@ export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { }); const revertedSnapshot = useExtensionState(VersioningExtension, { selector: (state) => - snapshot?.revertedSnapshotId !== undefined + snapshot?.meta.restoredFromSnapshotId !== undefined ? state.snapshots.find( - (snap) => snap.id === snapshot.revertedSnapshotId, + (snap) => snap.id === snapshot.meta.restoredFromSnapshotId, ) : undefined, }); const dateString = dateToString(new Date(snapshot?.createdAt || 0)); + const [snapshotName, setSnapshotName] = useState( + snapshot?.name || dateString, + ); if (snapshot === undefined) { return null; @@ -36,10 +40,9 @@ export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { { - updateSnapshotName?.(snapshot.id, event.target.value); - }} + value={snapshotName} + onChange={(e) => setSnapshotName(e.target.value)} + onBlur={() => updateSnapshotName?.(snapshot.id, snapshotName)} /> {snapshot.name && snapshot.name !== dateString && (
{dateString}
diff --git a/packages/react/src/components/Versioning/VersioningSidebar.tsx b/packages/react/src/components/Versioning/VersioningSidebar.tsx index 8e1af95b4e..840773c1f7 100644 --- a/packages/react/src/components/Versioning/VersioningSidebar.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebar.tsx @@ -10,8 +10,8 @@ export const VersioningSidebar = () => { return (
- {snapshots.map((snapshot, index) => { - return ; + {snapshots.map((snapshot) => { + return ; })}
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23b7b4b52b..f9050454fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3854,6 +3854,58 @@ importers: specifier: ^5.4.20 version: 5.4.20(@types/node@24.8.1)(lightningcss@1.30.1)(terser@5.44.1) + examples/07-collaboration/09-versioning: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^8.3.4 + version: 8.3.4(@mantine/hooks@8.3.4(react@19.2.1))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@mantine/hooks': + specifier: ^8.3.4 + version: 8.3.4(react@19.2.1) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(react@19.2.1) + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.1 + version: 19.2.1(react@19.2.1) + y-partykit: + specifier: ^0.0.25 + version: 0.0.25 + yjs: + specifier: ^13.6.27 + version: 13.6.27 + devDependencies: + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@5.4.20(@types/node@24.8.1)(lightningcss@1.30.1)(terser@5.44.1)) + vite: + specifier: ^5.4.20 + version: 5.4.20(@types/node@24.8.1)(lightningcss@1.30.1)(terser@5.44.1) + examples/08-extensions/01-tiptap-arrow-conversion: dependencies: '@blocknote/ariakit': From c8ca3f874dbef95584e59893673df11b2c721630 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 18 Dec 2025 14:40:55 +0100 Subject: [PATCH 03/10] Misc fixes --- .../09-versioning/src/App.tsx | 13 +- .../09-versioning/src/style.css | 16 +- .../src/extensions/Versioning/Versioning.ts | 145 +++--------------- .../Versioning/localStorageEndpoints.ts | 2 + .../src/components/Versioning/Snapshot.tsx | 24 ++- 5 files changed, 57 insertions(+), 143 deletions(-) diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx index 6669a5bf55..b58d12cb80 100644 --- a/examples/07-collaboration/09-versioning/src/App.tsx +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -6,23 +6,21 @@ import { import { BlockNoteViewEditor, useCreateBlockNote, + useExtensionState, VersioningSidebar, } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import * as Y from "yjs"; import "./style.css"; -// import YPartyKitProvider from "y-partykit/provider"; -import * as Y from "yjs"; const doc = new Y.Doc(); export default function App() { const editor = useCreateBlockNote({ collaboration: { - // Where to store BlockNote data in the Y.Doc: fragment: doc.getXmlFragment(), - // Information (name and color) for this user: user: { name: "My Username", color: "#ff0000", @@ -36,7 +34,10 @@ export default function App() { ], }); - // Renders the editor instance. + const { selectedSnapshotId } = useExtensionState(VersioningExtension, { + editor, + }); + return ( console.log(doc.getXmlFragment().toJSON())} >
-

Editor

+

Editor {selectedSnapshotId !== undefined ? "(Preview)" : ""}

diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css index 283cc13697..8d8bb5fdee 100644 --- a/examples/07-collaboration/09-versioning/src/style.css +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -16,7 +16,6 @@ flex-direction: column; gap: 10px; max-height: 100%; - min-width: 350px; width: 0; } @@ -79,11 +78,10 @@ } .bn-snapshot-button { - /* align-self: flex-end; */ - background-color: #c2dcf8; + background-color: #4da3ff; border: none; border-radius: 4px; - color: var(--bn-colors-menu-text); + color: var(--bn-colors-selected-text); cursor: pointer; font-size: 12px; font-weight: 600; @@ -92,7 +90,15 @@ } .dark .bn-snapshot-button { - background-color: #226fb7; + background-color: #0070e8; +} + +.bn-snapshot-button:hover { + background-color: #73b7ff; +} + +.dark .bn-snapshot-button:hover { + background-color: #3785d8; } .bn-versioning-sidebar .bn-snapshot.selected { diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index 5b194b4cdc..e13ac97a85 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -44,13 +44,6 @@ export interface VersionSnapshot { * Additional metadata about the snapshot. */ [key: string]: unknown; - - // TODO this should not be exposed to the user (make it internal only) - /** - * The content of the snapshot. - */ - contents?: Uint8Array; - selected?: boolean; }; } @@ -150,17 +143,25 @@ export const VersioningExtension = createExtension( snapshots, })); }; - const updateSnapshotsSync = () => { - updateSnapshots(); + + const initSnapshots = async () => { + await updateSnapshots(); + + if (store.state.snapshots.length > 0) { + const snapshotContent = await endpoints.fetchSnapshotContent( + store.state.snapshots[0].id, + ); + + applySnapshot(snapshotContent); + } }; return { key: "versioning", store, - mount: () => updateSnapshotsSync(), - // TODO I'd probably have: - // canRestoreSnapshot: () => boolean; - // canUpdateSnapshotName: () => boolean; + mount: () => { + initSnapshots(); + }, listSnapshots: async (): Promise => { await updateSnapshots(); @@ -172,6 +173,7 @@ export const VersioningExtension = createExtension( return store.state.snapshots[0]; }, + canRestoreSnapshot: endpoints.restoreSnapshot !== undefined, restoreSnapshot: endpoints.restoreSnapshot ? async (id: string): Promise => { const snapshotContent = await endpoints.restoreSnapshot!( @@ -181,22 +183,15 @@ export const VersioningExtension = createExtension( applySnapshot(snapshotContent); await updateSnapshots(); + store.setState((state) => ({ + ...state, + selectedSnapshotId: undefined, + })); + return snapshotContent; } : undefined, - fetchSnapshotContent: async (id: string): Promise => { - const storeSnapshot = store.state.snapshots.find( - (snapshot) => snapshot.id === id, - ); - if (storeSnapshot && storeSnapshot.meta.contents !== undefined) { - return storeSnapshot.meta.contents; - } - - const snapshotContent = await endpoints.fetchSnapshotContent(id); - await updateSnapshots(); - - return snapshotContent; - }, + canUpdateSnapshotName: endpoints.updateSnapshotName !== undefined, updateSnapshotName: endpoints.updateSnapshotName ? async (id: string, name: string): Promise => { await endpoints.updateSnapshotName!(id, name); @@ -226,101 +221,3 @@ export const VersioningExtension = createExtension( } as const; }, ); - -// /** -// * Here is a mock implementation of the VersionAPI for the demo -// */ -// export const MockVersionAPI = Object.assign( -// { -// listSnapshots: async () => { -// await new Promise((resolve) => -// setTimeout(resolve, 600 + Math.random() * 1000), -// ); -// return JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// ) as VersionSnapshot[]; -// }, -// createSnapshot: async (name, fragment) => { -// await new Promise((resolve) => -// setTimeout(resolve, 600 + Math.random() * 1000), -// ); -// const snapshot = { -// id: v4(), -// name, -// createdAt: Date.now(), -// updatedAt: Date.now(), -// meta: { -// userIds: ["User"], -// // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome -// contents: Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), -// }, -// } satisfies VersionSnapshot; -// localStorage.setItem( -// "versions", -// JSON.stringify([ -// snapshot, -// ...(JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// ) as VersionSnapshot[]), -// ]), -// ); -// return Promise.resolve(snapshot); -// }, -// fetchSnapshotContent: async (id: string) => { -// await new Promise((resolve) => -// setTimeout(resolve, 600 + Math.random() * 1000), -// ); -// const snapshot = ( -// JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// ) as VersionSnapshot[] -// ).find((snapshot) => snapshot.id === id); -// if (snapshot === undefined) { -// throw new Error(`Document snapshot ${id} could not be found.`); -// } -// const binaryString = atob(snapshot.meta.contents as string); -// const uint8Array = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); -// return Promise.resolve(uint8Array); -// }, -// restoreSnapshot: async (id: string) => { -// await new Promise((resolve) => -// setTimeout(resolve, 600 + Math.random() * 1000), -// ); -// const versions = JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// ) as VersionSnapshot[]; -// const snapshotIndex = versions.findIndex( -// (snapshot: VersionSnapshot) => snapshot.id === id, -// ); -// if (snapshotIndex === -1) { -// throw new Error(`Document snapshot ${id} could not be found.`); -// } -// const snapshot = versions[snapshotIndex].meta.contents as string; -// const binaryString = atob(snapshot); -// const uint8Array = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); -// return Promise.resolve(uint8Array); -// }, -// updateSnapshotName: async (id: string, name: string) => { -// await new Promise((resolve) => -// setTimeout(resolve, 600 + Math.random() * 1000), -// ); -// const snapshot = JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// ).find((snapshot: VersionSnapshot) => snapshot.id === id); -// if (snapshot === undefined) { -// throw new Error(`Document snapshot ${id} could not be found.`); -// } -// snapshot.name = name; -// localStorage.setItem("versions", JSON.stringify(snapshot)); -// return Promise.resolve(); -// }, -// } satisfies VersioningEndpoints, -// { -// // This will load the initial snapshot from the localStorage for the demo -// getInitialSnapshot: () => { -// return JSON.parse( -// localStorage.getItem("versions") ?? "[]", -// )[0] as VersionSnapshot; -// }, -// }, -// ); diff --git a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts index b1cac42bf0..f5a9e3a837 100644 --- a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts +++ b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts @@ -88,6 +88,8 @@ const updateSnapshotName: VersioningEndpoints["updateSnapshotName"] = async ( } snapshot.name = name; + snapshot.updatedAt = Date.now(); + localStorage.setItem("snapshots", JSON.stringify(snapshots)); return Promise.resolve(); diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx index 07c9094a22..228b65c6ca 100644 --- a/packages/react/src/components/Versioning/Snapshot.tsx +++ b/packages/react/src/components/Versioning/Snapshot.tsx @@ -8,8 +8,13 @@ import { dateToString } from "./dateToString.js"; import { useState } from "react"; export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { - const { restoreSnapshot, updateSnapshotName, selectSnapshot } = - useExtension(VersioningExtension); + const { + canRestoreSnapshot, + restoreSnapshot, + canUpdateSnapshotName, + updateSnapshotName, + selectSnapshot, + } = useExtension(VersioningExtension); const selected = useExtensionState(VersioningExtension, { selector: (state) => state.selectedSnapshotId === snapshot.id, }); @@ -40,6 +45,7 @@ export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { setSnapshotName(e.target.value)} onBlur={() => updateSnapshotName?.(snapshot.id, snapshotName)} @@ -56,12 +62,14 @@ export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => {
{`Edited by ${snapshot.meta.userIds.join(", ")}`}
)}
- + {canRestoreSnapshot && ( + + )} ); }; From 387e8f541ae286b9666ab1e9670d7727dca2f727 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 19 Dec 2025 13:57:20 +0100 Subject: [PATCH 04/10] Fixed issues with versioning --- .../09-versioning/src/App.tsx | 1 - .../09-versioning/src/style.css | 4 ++ .../src/extensions/Versioning/Versioning.ts | 47 +++++++++---------- .../components/Versioning/CurrentSnapshot.tsx | 6 ++- .../src/components/Versioning/Snapshot.tsx | 10 +++- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx index b58d12cb80..9d2dd4304c 100644 --- a/examples/07-collaboration/09-versioning/src/App.tsx +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -43,7 +43,6 @@ export default function App() { className={"version-history-main-container"} editor={editor} renderEditor={false} - onChange={() => console.log(doc.getXmlFragment().toJSON())} >

Editor {selectedSnapshotId !== undefined ? "(Preview)" : ""}

diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css index 8d8bb5fdee..4078a3a631 100644 --- a/examples/07-collaboration/09-versioning/src/style.css +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -70,6 +70,10 @@ width: 100%; } +.bn-snapshot-name:focus { + outline: none; +} + .bn-snapshot-body { display: flex; flex-direction: column; diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index e13ac97a85..c2d59533f1 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -156,6 +156,26 @@ export const VersioningExtension = createExtension( } }; + const selectSnapshot = async (id: string | undefined) => { + store.setState((state) => ({ + ...state, + selectedSnapshotId: id, + })); + + if (id === undefined) { + editor.isEditable = true; + // when we go back to the original document, just revert changes `.merge({ keepChanges: false })` + editor.getExtension(ForkYDocExtension)!.merge({ keepChanges: false }); + return; + } + editor.getExtension(ForkYDocExtension)!.fork(); + editor.isEditable = false; + const snapshotContent = await endpoints.fetchSnapshotContent(id); + + // replace editor contents with the snapshot contents (affecting the forked document not the original) + applySnapshot(snapshotContent); + }; + return { key: "versioning", store, @@ -176,6 +196,8 @@ export const VersioningExtension = createExtension( canRestoreSnapshot: endpoints.restoreSnapshot !== undefined, restoreSnapshot: endpoints.restoreSnapshot ? async (id: string): Promise => { + selectSnapshot(undefined); + const snapshotContent = await endpoints.restoreSnapshot!( fragment, id, @@ -183,11 +205,6 @@ export const VersioningExtension = createExtension( applySnapshot(snapshotContent); await updateSnapshots(); - store.setState((state) => ({ - ...state, - selectedSnapshotId: undefined, - })); - return snapshotContent; } : undefined, @@ -199,25 +216,7 @@ export const VersioningExtension = createExtension( } : undefined, - selectSnapshot: async (id: string | undefined) => { - store.setState((state) => ({ - ...state, - selectedSnapshotId: id, - })); - - if (id === undefined) { - editor.isEditable = true; - // when we go back to the original document, just revert changes `.merge({ keepChanges: false })` - editor.getExtension(ForkYDocExtension)!.merge({ keepChanges: false }); - return; - } - editor.getExtension(ForkYDocExtension)!.fork(); - editor.isEditable = false; - const snapshotContent = await endpoints.fetchSnapshotContent(id); - - // replace editor contents with the snapshot contents (affecting the forked document not the original) - applySnapshot(snapshotContent); - }, + selectSnapshot, } as const; }, ); diff --git a/packages/react/src/components/Versioning/CurrentSnapshot.tsx b/packages/react/src/components/Versioning/CurrentSnapshot.tsx index 61ebf17a97..d248efe369 100644 --- a/packages/react/src/components/Versioning/CurrentSnapshot.tsx +++ b/packages/react/src/components/Versioning/CurrentSnapshot.tsx @@ -29,7 +29,11 @@ export const CurrentSnapshot = () => {
From a57a3c6c239c191410acec005f106ed5f463554d Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 22 Dec 2025 10:32:58 +0100 Subject: [PATCH 05/10] Added comments to demo and overhauled UI/UX --- .../09-versioning/.bnexample.json | 2 +- .../09-versioning/package.json | 2 +- .../09-versioning/src/App.tsx | 149 +++++++++++++++-- .../09-versioning/src/CommentsSidebar.tsx | 65 ++++++++ .../09-versioning/src/SettingsSelect.tsx | 24 +++ .../src/VersionHistorySidebar.tsx | 33 ++++ .../09-versioning/src/style.css | 155 +++++++++++++++--- .../09-versioning/src/userdata.ts | 47 ++++++ .../src/extensions/Versioning/Versioning.ts | 6 +- .../src/components/Versioning/Snapshot.tsx | 7 +- .../Versioning/VersioningSidebar.tsx | 12 +- playground/src/examples.gen.tsx | 2 +- pnpm-lock.yaml | 6 +- 13 files changed, 458 insertions(+), 52 deletions(-) create mode 100644 examples/07-collaboration/09-versioning/src/CommentsSidebar.tsx create mode 100644 examples/07-collaboration/09-versioning/src/SettingsSelect.tsx create mode 100644 examples/07-collaboration/09-versioning/src/VersionHistorySidebar.tsx create mode 100644 examples/07-collaboration/09-versioning/src/userdata.ts diff --git a/examples/07-collaboration/09-versioning/.bnexample.json b/examples/07-collaboration/09-versioning/.bnexample.json index 851eb5071c..48716e7e4b 100644 --- a/examples/07-collaboration/09-versioning/.bnexample.json +++ b/examples/07-collaboration/09-versioning/.bnexample.json @@ -4,7 +4,7 @@ "author": "matthewlipski", "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { - "y-partykit": "^0.0.25", + "react-icons": "^5.2.1", "yjs": "^13.6.27" } } diff --git a/examples/07-collaboration/09-versioning/package.json b/examples/07-collaboration/09-versioning/package.json index bae34d14bd..4405f75c13 100644 --- a/examples/07-collaboration/09-versioning/package.json +++ b/examples/07-collaboration/09-versioning/package.json @@ -21,7 +21,7 @@ "@mantine/utils": "^6.0.22", "react": "^19.2.1", "react-dom": "^19.2.1", - "y-partykit": "^0.0.25", + "react-icons": "^5.2.1", "yjs": "^13.6.27" }, "devDependencies": { diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx index 9d2dd4304c..4842117588 100644 --- a/examples/07-collaboration/09-versioning/src/App.tsx +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -5,28 +5,62 @@ import { } from "@blocknote/core/extensions"; import { BlockNoteViewEditor, + FloatingComposerController, useCreateBlockNote, + useExtension, useExtensionState, - VersioningSidebar, } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useMemo, useState } from "react"; +import { RiChat3Line, RiHistoryLine } from "react-icons/ri"; import * as Y from "yjs"; +import { getRandomColor, HARDCODED_USERS, MyUserType } from "./userdata"; +import { SettingsSelect } from "./SettingsSelect"; import "./style.css"; +import { + YjsThreadStore, + DefaultThreadStoreAuth, + CommentsExtension, +} from "@blocknote/core/comments"; + +import { CommentsSidebar } from "./CommentsSidebar"; +import { VersionHistorySidebar } from "./VersionHistorySidebar"; const doc = new Y.Doc(); +async function resolveUsers(userIds: string[]) { + // fake a (slow) network request + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return HARDCODED_USERS.filter((user) => userIds.includes(user.id)); +} + export default function App() { + const [activeUser, setActiveUser] = useState(HARDCODED_USERS[0]); + const [editingMode, setEditingMode] = useState<"editing" | "suggestions">( + "editing", + ); + const [sidebar, setSidebar] = useState< + "comments" | "versionHistory" | "none" + >("none"); + + const threadStore = useMemo(() => { + return new YjsThreadStore( + activeUser.id, + doc.getMap("threads"), + new DefaultThreadStoreAuth(activeUser.id, activeUser.role), + ); + }, [doc, activeUser]); + const editor = useCreateBlockNote({ collaboration: { fragment: doc.getXmlFragment(), - user: { - name: "My Username", - color: "#ff0000", - }, + user: { color: getRandomColor(), name: activeUser.username }, }, extensions: [ + CommentsExtension({ threadStore, resolveUsers }), VersioningExtension({ endpoints: localStorageEndpoints, fragment: doc.getXmlFragment(), @@ -34,23 +68,112 @@ export default function App() { ], }); + const { selectSnapshot } = useExtension(VersioningExtension, { editor }); const { selectedSnapshotId } = useExtensionState(VersioningExtension, { editor, }); return ( -
-

Editor {selectedSnapshotId !== undefined ? "(Preview)" : ""}

- -
-
-

Version History

- +
+ {/* We place the editor, the sidebar, and any settings selects within + `BlockNoteView` as they use BlockNote UI components and need the context + for them. */} +
+
+
{ + setSidebar((sidebar) => + sidebar !== "versionHistory" ? "versionHistory" : "none", + ); + selectSnapshot(undefined); + }} + > + + Version History +
+
+ setSidebar((sidebar) => + sidebar !== "comments" ? "comments" : "none", + ) + } + > + + Comments +
+
+
+ {/*

Editor

*/} + {selectedSnapshotId === undefined && ( +
+ ({ + text: `${user.username} (${ + user.role === "editor" ? "Editor" : "Commenter" + })`, + icon: null, + onClick: () => { + setActiveUser(user); + }, + isSelected: user.id === activeUser.id, + }))} + /> + { + setEditingMode("editing"); + }, + isSelected: editingMode === "editing", + }, + { + text: "Suggestions", + icon: null, + onClick: () => { + setEditingMode("suggestions"); + }, + isSelected: editingMode === "suggestions", + }, + ]} + /> +
+ )} + {/* Because we set `renderEditor` to false, we can now manually place + `BlockNoteViewEditor` (the actual editor component) in its own + section below the user settings select. */} + + {/* Since we disabled rendering of comments with `comments={false}`, + we need to re-add the floating composer, which is the UI element that + appears when creating new threads. */} + {sidebar === "comments" && } +
+
+ {sidebar === "comments" && } + {sidebar === "versionHistory" && }
); diff --git a/examples/07-collaboration/09-versioning/src/CommentsSidebar.tsx b/examples/07-collaboration/09-versioning/src/CommentsSidebar.tsx new file mode 100644 index 0000000000..cd89ff82b7 --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/CommentsSidebar.tsx @@ -0,0 +1,65 @@ +import { ThreadsSidebar } from "@blocknote/react"; +import { useState } from "react"; + +import { SettingsSelect } from "./SettingsSelect"; + +export const CommentsSidebar = () => { + const [filter, setFilter] = useState<"open" | "resolved" | "all">("open"); + const [sort, setSort] = useState<"position" | "recent-activity" | "oldest">( + "position", + ); + + return ( +
+
+ setFilter("all"), + isSelected: filter === "all", + }, + { + text: "Open", + icon: null, + onClick: () => setFilter("open"), + isSelected: filter === "open", + }, + { + text: "Resolved", + icon: null, + onClick: () => setFilter("resolved"), + isSelected: filter === "resolved", + }, + ]} + /> + setSort("position"), + isSelected: sort === "position", + }, + { + text: "Recent activity", + icon: null, + onClick: () => setSort("recent-activity"), + isSelected: sort === "recent-activity", + }, + { + text: "Oldest", + icon: null, + onClick: () => setSort("oldest"), + isSelected: sort === "oldest", + }, + ]} + /> +
+ +
+ ); +}; diff --git a/examples/07-collaboration/09-versioning/src/SettingsSelect.tsx b/examples/07-collaboration/09-versioning/src/SettingsSelect.tsx new file mode 100644 index 0000000000..0dfc79dc3f --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/SettingsSelect.tsx @@ -0,0 +1,24 @@ +import { ComponentProps, useComponentsContext } from "@blocknote/react"; + +// This component is used to display a selection dropdown with a label. By using +// the useComponentsContext hook, we can create it out of existing components +// within the same UI library that `BlockNoteView` uses (Mantine, Ariakit, or +// ShadCN), to match the design of the editor. +export const SettingsSelect = (props: { + label: string; + items: ComponentProps["FormattingToolbar"]["Select"]["items"]; +}) => { + const Components = useComponentsContext()!; + + return ( +
+ +

{props.label + ":"}

+ +
+
+ ); +}; diff --git a/examples/07-collaboration/09-versioning/src/VersionHistorySidebar.tsx b/examples/07-collaboration/09-versioning/src/VersionHistorySidebar.tsx new file mode 100644 index 0000000000..a37cd3b31b --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/VersionHistorySidebar.tsx @@ -0,0 +1,33 @@ +import { VersioningSidebar } from "@blocknote/react"; +import { useState } from "react"; + +import { SettingsSelect } from "./SettingsSelect"; + +export const VersionHistorySidebar = () => { + const [filter, setFilter] = useState<"named" | "all">("all"); + + return ( +
+
+ setFilter("all"), + isSelected: filter === "all", + }, + { + text: "Named", + icon: null, + onClick: () => setFilter("named"), + isSelected: filter === "named", + }, + ]} + /> +
+ +
+ ); +}; diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css index 4078a3a631..383310f2d9 100644 --- a/examples/07-collaboration/09-versioning/src/style.css +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -1,33 +1,92 @@ -.version-history-main-container { +.full-collaboration { + align-items: flex-end; background-color: var(--bn-colors-disabled-background); display: flex; + flex-direction: column; gap: 10px; height: 100%; max-width: none; + overflow: auto; padding: 10px; +} + +.full-collaboration .full-collaboration-main-container { + display: flex; + gap: 10px; + height: 100%; + max-width: none; width: 100%; } -.version-history-main-container .editor-section, -.version-history-section { - border-radius: var(--bn-border-radius-large); +.full-collaboration .editor-layout-wrapper { + align-items: center; display: flex; - flex: 1; + flex: 2; flex-direction: column; gap: 10px; + justify-content: center; + width: 100%; +} + +.full-collaboration .sidebar-selectors { + align-items: center; + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + max-width: 700px; + width: 100%; +} + +.full-collaboration .sidebar-selector { + align-items: center; + background-color: var(--bn-colors-menu-background); + border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); + color: var(--bn-colors-menu-text); + cursor: pointer; + display: flex; + flex-direction: row; + font-family: var(--bn-font-family); + font-weight: 600; + gap: 8px; + justify-content: center; + padding: 10px; + user-select: none; + width: 100%; +} + +.full-collaboration .sidebar-selector:hover { + background-color: var(--bn-colors-hovered-background); + color: var(--bn-colors-hovered-text); +} + +.full-collaboration .sidebar-selector.selected { + background-color: var(--bn-colors-selected-background); + color: var(--bn-colors-selected-text); +} + +.full-collaboration .editor-section, +.full-collaboration .sidebar-section { + border-radius: var(--bn-border-radius-large); + box-shadow: var(--bn-shadow-medium); + display: flex; + flex-direction: column; max-height: 100%; - width: 0; + min-width: 350px; + width: 100%; } -.version-history-main-container .editor-section h1, -.version-history-section h1 { +.full-collaboration .editor-section h1, +.full-collaboration .sidebar-section h1 { color: var(--bn-colors-menu-text); margin: 0; font-size: 32px; } -.version-history-main-container .bn-editor, -.bn-versioning-sidebar { +.full-collaboration .bn-editor, +.full-collaboration .bn-threads-sidebar, +.full-collaboration .bn-versioning-sidebar { border-radius: var(--bn-border-radius-medium); display: flex; flex-direction: column; @@ -36,20 +95,68 @@ overflow: auto; } -.version-history-main-container .editor-section { +.full-collaboration .editor-section { + background-color: var(--bn-colors-editor-background); + border-radius: var(--bn-border-radius-large); + flex: 1; + gap: 16px; max-width: 700px; + padding-block: 16px; +} + +.full-collaboration .editor-section .settings { + padding-inline: 54px; +} + +.full-collaboration .sidebar-section { + background-color: var(--bn-colors-editor-background); + border-radius: var(--bn-border-radius-large); + width: 350px; +} + +.full-collaboration .sidebar-section .settings { + padding-block: 16px; + padding-inline: 16px; +} + +.full-collaboration .bn-threads-sidebar, +.full-collaboration .bn-versioning-sidebar { + padding-inline: 16px; } -.bn-versioning-sidebar { +.full-collaboration .settings { display: flex; - flex-direction: column; - gap: 8px; + flex-wrap: wrap; + gap: 10px; +} + +.full-collaboration .settings-select { + display: flex; + gap: 10px; +} + +.full-collaboration .settings-select .bn-toolbar { + align-items: center; +} + +.full-collaboration .settings-select h2 { + color: var(--bn-colors-menu-text); + margin: 0; + font-size: 12px; + line-height: 12px; + padding-left: 14px; +} + +.full-collaboration .bn-threads-sidebar > .bn-thread { + box-shadow: var(--bn-shadow-medium); + min-width: auto; } -.bn-snapshot { +.full-collaboration .bn-snapshot { background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); color: var(--bn-colors-menu-text); cursor: pointer; flex-direction: column; @@ -60,7 +167,7 @@ width: 100%; } -.bn-snapshot-name { +.full-collaboration .bn-snapshot-name { background: transparent; border: none; color: var(--bn-colors-menu-text); @@ -70,18 +177,18 @@ width: 100%; } -.bn-snapshot-name:focus { +.full-collaboration .bn-snapshot-name:focus { outline: none; } -.bn-snapshot-body { +.full-collaboration .bn-snapshot-body { display: flex; flex-direction: column; font-size: 12px; gap: 4px; } -.bn-snapshot-button { +.full-collaboration .bn-snapshot-button { background-color: #4da3ff; border: none; border-radius: 4px; @@ -93,24 +200,24 @@ width: fit-content; } -.dark .bn-snapshot-button { +.full-collaboration.dark .bn-snapshot-button { background-color: #0070e8; } -.bn-snapshot-button:hover { +.full-collaboration .bn-snapshot-button:hover { background-color: #73b7ff; } -.dark .bn-snapshot-button:hover { +.full-collaboration.dark .bn-snapshot-button:hover { background-color: #3785d8; } -.bn-versioning-sidebar .bn-snapshot.selected { +.full-collaboration .bn-versioning-sidebar .bn-snapshot.selected { background-color: #f5f9fd; border: 2px solid #c2dcf8; } -.dark .bn-versioning-sidebar .bn-snapshot.selected { +.full-collaboration.dark .bn-versioning-sidebar .bn-snapshot.selected { background-color: #20242a; border: 2px solid #23405b; } diff --git a/examples/07-collaboration/09-versioning/src/userdata.ts b/examples/07-collaboration/09-versioning/src/userdata.ts new file mode 100644 index 0000000000..c54eaf0f9a --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/userdata.ts @@ -0,0 +1,47 @@ +import type { User } from "@blocknote/core/comments"; + +const colors = [ + "#958DF1", + "#F98181", + "#FBBC88", + "#FAF594", + "#70CFF8", + "#94FADB", + "#B9F18D", +]; + +const getRandomElement = (list: any[]) => + list[Math.floor(Math.random() * list.length)]; + +export const getRandomColor = () => getRandomElement(colors); + +export type MyUserType = User & { + role: "editor" | "comment"; +}; + +export const HARDCODED_USERS: MyUserType[] = [ + { + id: "1", + username: "John Doe", + avatarUrl: "https://placehold.co/100x100?text=John", + role: "editor", + }, + { + id: "2", + username: "Jane Doe", + avatarUrl: "https://placehold.co/100x100?text=Jane", + role: "editor", + }, + { + id: "3", + username: "Bob Smith", + avatarUrl: "https://placehold.co/100x100?text=Bob", + role: "comment", + }, + { + id: "4", + username: "Betty Smith", + avatarUrl: "https://placehold.co/100x100?text=Betty", + role: "comment", + }, +]; diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index c2d59533f1..08ebfbe7c0 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -97,7 +97,7 @@ export interface VersioningEndpoints { * * @note if not provided, the UI will not allow the user to update the name */ - updateSnapshotName?: (id: string, name: string) => Promise; + updateSnapshotName?: (id: string, name?: string) => Promise; } export const VersioningExtension = createExtension( @@ -163,13 +163,11 @@ export const VersioningExtension = createExtension( })); if (id === undefined) { - editor.isEditable = true; // when we go back to the original document, just revert changes `.merge({ keepChanges: false })` editor.getExtension(ForkYDocExtension)!.merge({ keepChanges: false }); return; } editor.getExtension(ForkYDocExtension)!.fork(); - editor.isEditable = false; const snapshotContent = await endpoints.fetchSnapshotContent(id); // replace editor contents with the snapshot contents (affecting the forked document not the original) @@ -210,7 +208,7 @@ export const VersioningExtension = createExtension( : undefined, canUpdateSnapshotName: endpoints.updateSnapshotName !== undefined, updateSnapshotName: endpoints.updateSnapshotName - ? async (id: string, name: string): Promise => { + ? async (id: string, name?: string): Promise => { await endpoints.updateSnapshotName!(id, name); await updateSnapshots(); } diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx index 1945ba3e9d..9f98d523aa 100644 --- a/packages/react/src/components/Versioning/Snapshot.tsx +++ b/packages/react/src/components/Versioning/Snapshot.tsx @@ -48,7 +48,12 @@ export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => { readOnly={!canUpdateSnapshotName} value={snapshotName} onChange={(e) => setSnapshotName(e.target.value)} - onBlur={() => updateSnapshotName?.(snapshot.id, snapshotName)} + onBlur={() => + updateSnapshotName?.( + snapshot.id, + snapshotName === dateString ? undefined : snapshotName, + ) + } /> {snapshot.name && snapshot.name !== dateString && (
{dateString}
diff --git a/packages/react/src/components/Versioning/VersioningSidebar.tsx b/packages/react/src/components/Versioning/VersioningSidebar.tsx index 840773c1f7..00af0a8891 100644 --- a/packages/react/src/components/Versioning/VersioningSidebar.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebar.tsx @@ -4,15 +4,19 @@ import { useExtensionState } from "../../hooks/useExtension.js"; import { CurrentSnapshot } from "./CurrentSnapshot.js"; import { Snapshot } from "./Snapshot.js"; -export const VersioningSidebar = () => { +export const VersioningSidebar = (props: { filter?: "named" | "all" }) => { const { snapshots } = useExtensionState(VersioningExtension); return (
- {snapshots.map((snapshot) => { - return ; - })} + {snapshots + .filter((snapshot) => + props.filter === "named" ? snapshot.name !== undefined : true, + ) + .map((snapshot) => { + return ; + })}
); }; diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 9b175a391f..f8aecd0c4f 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1622,7 +1622,7 @@ "Collaboration" ], "dependencies": { - "y-partykit": "^0.0.25", + "react-icons": "^5.2.1", "yjs": "^13.6.27" } as any }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9050454fd..7cd70ab5e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3886,9 +3886,9 @@ importers: react-dom: specifier: ^19.2.1 version: 19.2.1(react@19.2.1) - y-partykit: - specifier: ^0.0.25 - version: 0.0.25 + react-icons: + specifier: ^5.2.1 + version: 5.5.0(react@19.2.1) yjs: specifier: ^13.6.27 version: 13.6.27 From 9d36465a20bc7bc5982b497070e82f873a23efd8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 22 Dec 2025 21:09:56 +0100 Subject: [PATCH 06/10] Added suggestions editing --- docs/package.json | 3 +- .../09-versioning/.bnexample.json | 1 + .../09-versioning/package.json | 1 + .../09-versioning/src/App.tsx | 36 +++- .../09-versioning/src/SuggestionActions.tsx | 31 +++ .../src/SuggestionActionsPopup.tsx | 176 ++++++++++++++++++ .../09-versioning/src/style.css | 20 ++ packages/core/package.json | 1 + packages/core/src/editor/BlockNoteEditor.ts | 10 + .../src/extensions/LinkToolbar/LinkToolbar.ts | 10 +- .../src/extensions/Suggestions/Suggestions.ts | 171 +++++++++++++++++ .../src/extensions/Versioning/Versioning.ts | 13 +- packages/core/src/extensions/index.ts | 1 + .../LinkToolbar/LinkToolbarController.tsx | 16 +- playground/src/examples.gen.tsx | 1 + pnpm-lock.yaml | 9 + 16 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 examples/07-collaboration/09-versioning/src/SuggestionActions.tsx create mode 100644 examples/07-collaboration/09-versioning/src/SuggestionActionsPopup.tsx create mode 100644 packages/core/src/extensions/Suggestions/Suggestions.ts diff --git a/docs/package.json b/docs/package.json index 51290a6cc4..25b4ba2576 100644 --- a/docs/package.json +++ b/docs/package.json @@ -103,7 +103,8 @@ "twoslash": "^0.3.4", "y-partykit": "^0.0.25", "yjs": "^13.6.27", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@floating-ui/react": "^0.27.16" }, "devDependencies": { "@blocknote/ariakit": "workspace:*", diff --git a/examples/07-collaboration/09-versioning/.bnexample.json b/examples/07-collaboration/09-versioning/.bnexample.json index 48716e7e4b..d1cc2f0db2 100644 --- a/examples/07-collaboration/09-versioning/.bnexample.json +++ b/examples/07-collaboration/09-versioning/.bnexample.json @@ -4,6 +4,7 @@ "author": "matthewlipski", "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { + "@floating-ui/react": "^0.27.16", "react-icons": "^5.2.1", "yjs": "^13.6.27" } diff --git a/examples/07-collaboration/09-versioning/package.json b/examples/07-collaboration/09-versioning/package.json index 4405f75c13..531997eaba 100644 --- a/examples/07-collaboration/09-versioning/package.json +++ b/examples/07-collaboration/09-versioning/package.json @@ -21,6 +21,7 @@ "@mantine/utils": "^6.0.22", "react": "^19.2.1", "react-dom": "^19.2.1", + "@floating-ui/react": "^0.27.16", "react-icons": "^5.2.1", "yjs": "^13.6.27" }, diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx index 4842117588..374c942efa 100644 --- a/examples/07-collaboration/09-versioning/src/App.tsx +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -1,18 +1,20 @@ import "@blocknote/core/fonts/inter.css"; import { localStorageEndpoints, + SuggestionsExtension, VersioningExtension, } from "@blocknote/core/extensions"; import { BlockNoteViewEditor, FloatingComposerController, useCreateBlockNote, + useEditorState, useExtension, useExtensionState, } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { RiChat3Line, RiHistoryLine } from "react-icons/ri"; import * as Y from "yjs"; @@ -27,6 +29,8 @@ import { import { CommentsSidebar } from "./CommentsSidebar"; import { VersionHistorySidebar } from "./VersionHistorySidebar"; +import { SuggestionActions } from "./SuggestionActions"; +import { SuggestionActionsPopup } from "./SuggestionActionsPopup"; const doc = new Y.Doc(); @@ -39,12 +43,6 @@ async function resolveUsers(userIds: string[]) { export default function App() { const [activeUser, setActiveUser] = useState(HARDCODED_USERS[0]); - const [editingMode, setEditingMode] = useState<"editing" | "suggestions">( - "editing", - ); - const [sidebar, setSidebar] = useState< - "comments" | "versionHistory" | "none" - >("none"); const threadStore = useMemo(() => { return new YjsThreadStore( @@ -61,6 +59,7 @@ export default function App() { }, extensions: [ CommentsExtension({ threadStore, resolveUsers }), + SuggestionsExtension(), VersioningExtension({ endpoints: localStorageEndpoints, fragment: doc.getXmlFragment(), @@ -68,11 +67,28 @@ export default function App() { ], }); + const { enableSuggestions, disableSuggestions, checkUnresolvedSuggestions } = + useExtension(SuggestionsExtension, { editor }); + const hasUnresolvedSuggestions = useEditorState({ + selector: () => checkUnresolvedSuggestions(), + editor, + }); + const { selectSnapshot } = useExtension(VersioningExtension, { editor }); const { selectedSnapshotId } = useExtensionState(VersioningExtension, { editor, }); + const [editingMode, setEditingMode] = useState<"editing" | "suggestions">( + "editing", + ); + useEffect(() => { + setEditingMode("editing"); + }, [selectedSnapshotId]); + const [sidebar, setSidebar] = useState< + "comments" | "versionHistory" | "none" + >("none"); + return ( { + disableSuggestions(); setEditingMode("editing"); }, isSelected: editingMode === "editing", @@ -154,18 +171,23 @@ export default function App() { text: "Suggestions", icon: null, onClick: () => { + enableSuggestions(); setEditingMode("suggestions"); }, isSelected: editingMode === "suggestions", }, ]} /> + {editingMode === "suggestions" && hasUnresolvedSuggestions && ( + + )}
)} {/* Because we set `renderEditor` to false, we can now manually place `BlockNoteViewEditor` (the actual editor component) in its own section below the user settings select. */} + {/* Since we disabled rendering of comments with `comments={false}`, we need to re-add the floating composer, which is the UI element that appears when creating new threads. */} diff --git a/examples/07-collaboration/09-versioning/src/SuggestionActions.tsx b/examples/07-collaboration/09-versioning/src/SuggestionActions.tsx new file mode 100644 index 0000000000..4bdc7dcf8b --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/SuggestionActions.tsx @@ -0,0 +1,31 @@ +import { SuggestionsExtension } from "@blocknote/core/extensions"; +import { useComponentsContext, useExtension } from "@blocknote/react"; +import { RiArrowGoBackLine, RiCheckLine } from "react-icons/ri"; + +export const SuggestionActions = () => { + const Components = useComponentsContext()!; + + const { applyAllSuggestions, revertAllSuggestions } = + useExtension(SuggestionsExtension); + + return ( + + } + onClick={() => applyAllSuggestions()} + mainTooltip="Apply All Changes" + > + {/* Apply All Changes */} + + } + onClick={() => revertAllSuggestions()} + mainTooltip="Revert All Changes" + > + {/* Revert All Changes */} + + + ); +}; diff --git a/examples/07-collaboration/09-versioning/src/SuggestionActionsPopup.tsx b/examples/07-collaboration/09-versioning/src/SuggestionActionsPopup.tsx new file mode 100644 index 0000000000..8d24cc6fa8 --- /dev/null +++ b/examples/07-collaboration/09-versioning/src/SuggestionActionsPopup.tsx @@ -0,0 +1,176 @@ +import { SuggestionsExtension } from "@blocknote/core/extensions"; +import { + FloatingUIOptions, + GenericPopover, + GenericPopoverReference, + useBlockNoteEditor, + useComponentsContext, + useExtension, +} from "@blocknote/react"; +import { flip, offset, safePolygon } from "@floating-ui/react"; +import { useEffect, useMemo, useState } from "react"; +import { RiArrowGoBackLine, RiCheckLine } from "react-icons/ri"; + +export const SuggestionActionsPopup = () => { + const Components = useComponentsContext()!; + + const editor = useBlockNoteEditor(); + + const [toolbarOpen, setToolbarOpen] = useState(false); + + const { + applySuggestion, + getSuggestionAtCoords, + getSuggestionAtSelection, + getSuggestionElementAtPos, + revertSuggestion, + } = useExtension(SuggestionsExtension); + + const [suggestion, setSuggestion] = useState< + | { + cursorType: "text" | "mouse"; + id: string; + element: HTMLElement; + } + | undefined + >(undefined); + + useEffect(() => { + const textCursorCallback = () => { + const textCursorSuggestion = getSuggestionAtSelection(); + if (!textCursorSuggestion) { + setSuggestion(undefined); + setToolbarOpen(false); + + return; + } + + setSuggestion({ + cursorType: "text", + id: textCursorSuggestion.mark.attrs.id as string, + element: getSuggestionElementAtPos(textCursorSuggestion.range.from)!, + }); + + setToolbarOpen(true); + }; + + const mouseCursorCallback = (event: MouseEvent) => { + if (suggestion !== undefined && suggestion.cursorType === "text") { + return; + } + + if (!(event.target instanceof HTMLElement)) { + return; + } + + const mouseCursorSuggestion = getSuggestionAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (!mouseCursorSuggestion) { + return; + } + + const element = getSuggestionElementAtPos( + mouseCursorSuggestion.range.from, + )!; + if (element === suggestion?.element) { + return; + } + + setSuggestion({ + cursorType: "mouse", + id: mouseCursorSuggestion.mark.attrs.id as string, + element: getSuggestionElementAtPos(mouseCursorSuggestion.range.from)!, + }); + }; + + const destroyOnChangeHandler = editor.onChange(textCursorCallback); + const destroyOnSelectionChangeHandler = + editor.onSelectionChange(textCursorCallback); + + editor.domElement?.addEventListener("mousemove", mouseCursorCallback); + + return () => { + destroyOnChangeHandler(); + destroyOnSelectionChangeHandler(); + + editor.domElement?.removeEventListener("mousemove", mouseCursorCallback); + }; + }, [editor.domElement, suggestion]); + + const floatingUIOptions = useMemo( + () => ({ + useFloatingOptions: { + open: toolbarOpen, + onOpenChange: (open, _event, reason) => { + if ( + suggestion !== undefined && + suggestion.cursorType === "text" && + reason === "hover" + ) { + return; + } + + if (reason === "escape-key") { + editor.focus(); + } + + setToolbarOpen(open); + }, + placement: "top-start", + middleware: [offset(10), flip()], + }, + useHoverProps: { + enabled: suggestion !== undefined && suggestion.cursorType === "mouse", + delay: { + open: 250, + close: 250, + }, + handleClose: safePolygon({ + blockPointerEvents: true, + }), + }, + elementProps: { + style: { + zIndex: 50, + }, + }, + }), + [editor, suggestion, toolbarOpen], + ); + + const reference = useMemo( + () => (suggestion?.element ? { element: suggestion.element } : undefined), + [suggestion?.element], + ); + + if (!editor.isEditable) { + return null; + } + + return ( + + {suggestion && ( + + } + onClick={() => applySuggestion(suggestion.id)} + mainTooltip="Apply Change" + > + {/* Apply Change */} + + } + onClick={() => revertSuggestion(suggestion.id)} + mainTooltip="Revert Change" + > + {/* Revert Change */} + + + )} + + ); +}; diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css index 383310f2d9..c0e0c36c91 100644 --- a/examples/07-collaboration/09-versioning/src/style.css +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -221,3 +221,23 @@ background-color: #20242a; border: 2px solid #23405b; } + +.full-collaboration ins { + background-color: hsl(120 100 90); + color: hsl(120 100 30); +} + +.dark.full-collaboration ins { + background-color: hsl(120 100 10); + color: hsl(120 80 70); +} + +.full-collaboration del { + background-color: hsl(0 100 90); + color: hsl(0 100 30); +} + +.dark.full-collaboration del { + background-color: hsl(0 100 10); + color: hsl(0 80 70); +} diff --git a/packages/core/package.json b/packages/core/package.json index 0209577129..2540a83e86 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,6 +91,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.3", + "@handlewithcare/prosemirror-suggest-changes": "^0.1.8", "@shikijs/types": "^3", "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.13.0", diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 8a11e64493..a68b1d0993 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -53,6 +53,8 @@ import { import type { Selection } from "./selectionTypes.js"; import { transformPasted } from "./transformPasted.js"; +import { withSuggestChanges } from "@handlewithcare/prosemirror-suggest-changes"; + export type BlockCache< BSchema extends BlockSchema = any, ISchema extends InlineContentSchema = any, @@ -547,6 +549,14 @@ export class BlockNoteEditor< ); } + const origDispatchTransaction = ( + this._tiptapEditor as any + ).dispatchTransaction.bind(this._tiptapEditor); + + (this._tiptapEditor as any).dispatchTransaction = withSuggestChanges( + origDispatchTransaction, + ); + // When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`. // This causes the unique id extension to generate a new id for the initial block, which is not what we want // Since it will be randomly generated & cause there to be more updates to the ydoc diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts index 1e6c71ee2e..38f3086b7b 100644 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts @@ -65,10 +65,14 @@ export const LinkToolbarExtension = createExtension(({ editor }) => { getLinkElementAtPos, getMarkAtPos, - getLinkAtElement(element: HTMLElement) { + getLinkAtCoords(coords: { left: number; top: number }) { return editor.transact(() => { - const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1; - return getMarkAtPos(posAtElement, "link"); + const posAtCoords = editor.prosemirrorView.posAtCoords(coords); + if (posAtCoords === null || posAtCoords.inside === -1) { + return undefined; + } + + return getMarkAtPos(posAtCoords.pos, "link"); }); }, diff --git a/packages/core/src/extensions/Suggestions/Suggestions.ts b/packages/core/src/extensions/Suggestions/Suggestions.ts new file mode 100644 index 0000000000..bb90db1f5b --- /dev/null +++ b/packages/core/src/extensions/Suggestions/Suggestions.ts @@ -0,0 +1,171 @@ +import { + applySuggestion, + applySuggestions, + disableSuggestChanges, + enableSuggestChanges, + revertSuggestion, + revertSuggestions, + suggestChanges, + withSuggestChanges, +} from "@handlewithcare/prosemirror-suggest-changes"; +import { getMarkRange, posToDOMRect } from "@tiptap/core"; + +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +export const SuggestionsExtension = createExtension(({ editor }) => { + function getSuggestionElementAtPos(pos: number) { + let currentNode = editor.prosemirrorView.nodeDOM(pos); + while (currentNode && currentNode.parentElement) { + if (currentNode.nodeName === "INS" || currentNode.nodeName === "DEL") { + return currentNode as HTMLElement; + } + currentNode = currentNode.parentElement; + } + return null; + } + + function getMarkAtPos(pos: number, markType: string) { + return editor.transact((tr) => { + const resolvedPos = tr.doc.resolve(pos); + const mark = resolvedPos + .marks() + .find((mark) => mark.type.name === markType); + + if (!mark) { + return; + } + + const markRange = getMarkRange(resolvedPos, mark.type); + if (!markRange) { + return; + } + + return { + range: markRange, + mark, + get text() { + return tr.doc.textBetween(markRange.from, markRange.to); + }, + get position() { + // to minimize re-renders, we convert to JSON, which is the same shape anyway + return posToDOMRect( + editor.prosemirrorView, + markRange.from, + markRange.to, + ).toJSON() as DOMRect; + }, + }; + }); + } + + function getSuggestionAtSelection() { + return editor.transact((tr) => { + const selection = tr.selection; + if (!selection.empty) { + return undefined; + } + return ( + getMarkAtPos(selection.anchor, "insertion") || + getMarkAtPos(selection.anchor, "deletion") || + getMarkAtPos(selection.anchor, "modification") + ); + }); + } + + return { + key: "suggestions", + prosemirrorPlugins: [suggestChanges()], + // mount: () => { + // const origDispatchTransaction = ( + // editor._tiptapEditor as any + // ).dispatchTransaction.bind(editor._tiptapEditor); + + // (editor._tiptapEditor as any).dispatchTransaction = withSuggestChanges( + // origDispatchTransaction, + // ); + // }, + enableSuggestions: () => + enableSuggestChanges( + editor.prosemirrorState, + (editor._tiptapEditor as any).dispatchTransaction.bind( + editor._tiptapEditor, + ), + ), + disableSuggestions: () => + disableSuggestChanges( + editor.prosemirrorState, + (editor._tiptapEditor as any).dispatchTransaction.bind( + editor._tiptapEditor, + ), + ), + applySuggestion: (id: string) => + applySuggestion(id)( + editor.prosemirrorState, + withSuggestChanges(editor.prosemirrorView.dispatch).bind( + editor._tiptapEditor, + ), + editor.prosemirrorView, + ), + revertSuggestion: (id: string) => + revertSuggestion(id)( + editor.prosemirrorState, + withSuggestChanges(editor.prosemirrorView.dispatch).bind( + editor._tiptapEditor, + ), + editor.prosemirrorView, + ), + applyAllSuggestions: () => + applySuggestions( + editor.prosemirrorState, + withSuggestChanges(editor.prosemirrorView.dispatch).bind( + editor._tiptapEditor, + ), + ), + revertAllSuggestions: () => + revertSuggestions( + editor.prosemirrorState, + withSuggestChanges(editor.prosemirrorView.dispatch).bind( + editor._tiptapEditor, + ), + ), + + getSuggestionElementAtPos, + getMarkAtPos, + getSuggestionAtSelection, + getSuggestionAtCoords: (coords: { left: number; top: number }) => { + return editor.transact(() => { + const posAtCoords = editor.prosemirrorView.posAtCoords(coords); + if (posAtCoords === null || posAtCoords?.inside === -1) { + return undefined; + } + + return ( + getMarkAtPos(posAtCoords.pos, "insertion") || + getMarkAtPos(posAtCoords.pos, "deletion") || + getMarkAtPos(posAtCoords.pos, "modification") + ); + }); + }, + checkUnresolvedSuggestions: () => { + let hasUnresolvedSuggestions = false; + + editor.prosemirrorState.doc.descendants((node) => { + if (hasUnresolvedSuggestions) { + return false; + } + + hasUnresolvedSuggestions = + node.marks.findIndex( + (mark) => + mark.type.name === "insertion" || + mark.type.name === "deletion" || + mark.type.name === "modification", + ) !== -1; + + return true; + }); + + return hasUnresolvedSuggestions; + }, + } as const; +}); diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index 08ebfbe7c0..e44f03c487 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -1,3 +1,4 @@ +import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; import * as Y from "yjs"; import { @@ -9,7 +10,8 @@ import { findTypeInOtherYdoc, ForkYDocExtension, } from "../Collaboration/ForkYDoc.js"; -import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; + +import { SuggestionsExtension } from "../Suggestions/Suggestions.js"; export interface VersionSnapshot { /** @@ -214,7 +216,14 @@ export const VersioningExtension = createExtension( } : undefined, - selectSnapshot, + selectSnapshot: async (id: string | undefined) => { + const suggestions = editor.getExtension(SuggestionsExtension); + if (suggestions !== undefined) { + suggestions.disableSuggestions(); + } + + await selectSnapshot(id); + }, } as const; }, ); diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index e4b3cb3c00..a4dd6a8104 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -20,6 +20,7 @@ export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js"; export * from "./SuggestionMenu/DefaultSuggestionItem.js"; export * from "./SuggestionMenu/DefaultGridSuggestionItem.js"; +export * from "./Suggestions/Suggestions.js"; export * from "./TableHandles/TableHandles.js"; export * from "./TrailingNode/TrailingNode.js"; export * from "./Versioning/Versioning.js"; diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 9271dd7fca..c6a52cff6f 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -80,11 +80,21 @@ export const LinkToolbarController = (props: { return; } - const mouseCursorLink = linkToolbar.getLinkAtElement(event.target); + const mouseCursorLink = linkToolbar.getLinkAtCoords({ + left: event.clientX, + top: event.clientY, + }); if (!mouseCursorLink) { return; } + const element = linkToolbar.getLinkElementAtPos( + mouseCursorLink.range.from, + )!; + if (element === link?.element) { + return; + } + setLink({ cursorType: "mouse", url: mouseCursorLink.mark.attrs.href as string, @@ -98,13 +108,13 @@ export const LinkToolbarController = (props: { const destroyOnSelectionChangeHandler = editor.onSelectionChange(textCursorCallback); - editor.domElement?.addEventListener("mouseover", mouseCursorCallback); + editor.domElement?.addEventListener("mousemove", mouseCursorCallback); return () => { destroyOnChangeHandler(); destroyOnSelectionChangeHandler(); - editor.domElement?.removeEventListener("mouseover", mouseCursorCallback); + editor.domElement?.removeEventListener("mousemove", mouseCursorCallback); }; }, [editor, linkToolbar, link, toolbarPositionFrozen]); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index f8aecd0c4f..dca1bff32e 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1622,6 +1622,7 @@ "Collaboration" ], "dependencies": { + "@floating-ui/react": "^0.27.16", "react-icons": "^5.2.1", "yjs": "^13.6.27" } as any diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cd70ab5e1..7b78919732 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@emotion/styled': specifier: ^11.11.5 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@types/react@19.2.2)(react@19.2.1) + '@floating-ui/react': + specifier: ^0.27.16 + version: 0.27.16(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@fumadocs/mdx-remote': specifier: 1.3.0 version: 1.3.0(fumadocs-core@15.5.4(@types/react@19.2.2)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) @@ -3871,6 +3874,9 @@ importers: '@blocknote/shadcn': specifier: latest version: link:../../../packages/shadcn + '@floating-ui/react': + specifier: ^0.27.16 + version: 0.27.16(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@mantine/core': specifier: ^8.3.4 version: 8.3.4(@mantine/hooks@8.3.4(react@19.2.1))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -4575,6 +4581,9 @@ importers: '@handlewithcare/prosemirror-inputrules': specifier: ^0.1.3 version: 0.1.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4) + '@handlewithcare/prosemirror-suggest-changes': + specifier: ^0.1.8 + version: 0.1.8(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.10.5)(prosemirror-view@1.41.4) '@hocuspocus/provider': specifier: ^2.15.2 || ^3.0.0 version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) From 117ccbffb80a6848ace0a541eb3d5d2a0592b5b6 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 22 Dec 2025 21:45:25 +0100 Subject: [PATCH 07/10] Minor changes --- .../07-collaboration/09-versioning/README.md | 12 +++-- .../09-versioning/src/App.tsx | 48 ++++++++++--------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/examples/07-collaboration/09-versioning/README.md b/examples/07-collaboration/09-versioning/README.md index b7aad68c90..528f98165e 100644 --- a/examples/07-collaboration/09-versioning/README.md +++ b/examples/07-collaboration/09-versioning/README.md @@ -1,9 +1,15 @@ -# Collaborative Editing with Versioning +# Collaborative Editing Features Showcase -In this example, we can save snapshots of the document that we can later preview and revert to. +In this example, you can play with all of the collaboration features BlockNote has to offer: -**Try it out:** Press the save button in the versioning sidebar to save a document snapshot! +**Comments**: Add comments to parts of the document - other users can then view, reply to, react to, and resolve them. + +**Versioning**: Save snapshots of the document - later preview saved snapshots and restore them to ensure work is never lost. + +**Suggestions**: Suggest changes directly in the editor - users can choose to then apply or reject those changes. **Relevant Docs:** - [Editor Setup](/docs/getting-started/editor-setup) +- [Comments](/docs/features/collaboration/comments) +- [Real-time collaboration](/docs/features/collaboration) \ No newline at end of file diff --git a/examples/07-collaboration/09-versioning/src/App.tsx b/examples/07-collaboration/09-versioning/src/App.tsx index 374c942efa..d026f29c33 100644 --- a/examples/07-collaboration/09-versioning/src/App.tsx +++ b/examples/07-collaboration/09-versioning/src/App.tsx @@ -155,32 +155,34 @@ export default function App() { isSelected: user.id === activeUser.id, }))} /> - { - disableSuggestions(); - setEditingMode("editing"); + {activeUser.role === "editor" && ( + { + disableSuggestions(); + setEditingMode("editing"); + }, + isSelected: editingMode === "editing", }, - isSelected: editingMode === "editing", - }, - { - text: "Suggestions", - icon: null, - onClick: () => { - enableSuggestions(); - setEditingMode("suggestions"); + { + text: "Suggestions", + icon: null, + onClick: () => { + enableSuggestions(); + setEditingMode("suggestions"); + }, + isSelected: editingMode === "suggestions", }, - isSelected: editingMode === "suggestions", - }, - ]} - /> - {editingMode === "suggestions" && hasUnresolvedSuggestions && ( - + ]} + /> )} + {activeUser.role === "editor" && + editingMode === "suggestions" && + hasUnresolvedSuggestions && } )} {/* Because we set `renderEditor` to false, we can now manually place From 7237856b58ec8583bee882ce4f26c9bfa7a32130 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 22 Dec 2025 21:50:09 +0100 Subject: [PATCH 08/10] Fixed thread style --- examples/07-collaboration/09-versioning/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/07-collaboration/09-versioning/src/style.css b/examples/07-collaboration/09-versioning/src/style.css index c0e0c36c91..aa3b2d8f6f 100644 --- a/examples/07-collaboration/09-versioning/src/style.css +++ b/examples/07-collaboration/09-versioning/src/style.css @@ -148,7 +148,7 @@ } .full-collaboration .bn-threads-sidebar > .bn-thread { - box-shadow: var(--bn-shadow-medium); + box-shadow: var(--bn-shadow-medium) !important; min-width: auto; } From 8a1e12aee035097aabf287c6d9a91357adbb87f3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 22 Dec 2025 22:04:39 +0100 Subject: [PATCH 09/10] Ran `pnpm gen` --- examples/07-collaboration/09-versioning/index.html | 2 +- playground/src/examples.gen.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/07-collaboration/09-versioning/index.html b/examples/07-collaboration/09-versioning/index.html index d05d50dbe6..42dc61461a 100644 --- a/examples/07-collaboration/09-versioning/index.html +++ b/examples/07-collaboration/09-versioning/index.html @@ -2,7 +2,7 @@ - Collaborative Editing with Versioning + Collaborative Editing Features Showcase diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index dca1bff32e..a3936a2947 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1627,12 +1627,12 @@ "yjs": "^13.6.27" } as any }, - "title": "Collaborative Editing with Versioning", + "title": "Collaborative Editing Features Showcase", "group": { "pathFromRoot": "examples/07-collaboration", "slug": "collaboration" }, - "readme": "In this example, we can save snapshots of the document that we can later preview and revert to. \n\n**Try it out:** Press the save button in the versioning sidebar to save a document snapshot!\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)" + "readme": "In this example, you can play with all of the collaboration features BlockNote has to offer:\n\n**Comments**: Add comments to parts of the document - other users can then view, reply to, react to, and resolve them.\n\n**Versioning**: Save snapshots of the document - later preview saved snapshots and restore them to ensure work is never lost.\n\n**Suggestions**: Suggest changes directly in the editor - users can choose to then apply or reject those changes.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Comments](/docs/features/collaboration/comments)\n- [Real-time collaboration](/docs/features/collaboration)" } ] }, From af6bf063f18dbee86b47a575b9bfd876690495b9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 23 Dec 2025 16:48:24 +0100 Subject: [PATCH 10/10] chore: use lib0 for base64 --- packages/core/package.json | 1 + .../Versioning/localStorageEndpoints.ts | 8 +++---- pnpm-lock.yaml | 22 ++++++++++++++----- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 2540a83e86..be1eb81384 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -109,6 +109,7 @@ "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "hast-util-from-dom": "^5.0.1", + "lib0": "0.2.116", "prosemirror-dropcursor": "^1.8.2", "prosemirror-highlight": "^0.13.0", "prosemirror-model": "^1.25.4", diff --git a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts index f5a9e3a837..7cce991506 100644 --- a/packages/core/src/extensions/Versioning/localStorageEndpoints.ts +++ b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts @@ -1,5 +1,6 @@ import { v4 } from "uuid"; import * as Y from "yjs"; +import { toBase64, fromBase64 } from "lib0/buffer"; import { VersioningEndpoints, VersionSnapshot } from "./Versioning.js"; @@ -19,8 +20,7 @@ const createSnapshot = async ( meta: { restoredFromSnapshotId, userIds: ["User1"], - // @ts-expect-error - toBase64 is not a method on Uint8Array in types, but exists in chrome - contents: Y.encodeStateAsUpdateV2(fragment.doc!).toBase64(), + contents: toBase64(Y.encodeStateAsUpdateV2(fragment.doc!)), }, } satisfies VersionSnapshot; @@ -49,9 +49,7 @@ const fetchSnapshotContent: VersioningEndpoints["fetchSnapshotContent"] = throw new Error(`Document snapshot ${id} contains invalid content.`); } - return Promise.resolve( - Uint8Array.from(atob(snapshot.meta.contents), (c) => c.charCodeAt(0)), - ); + return Promise.resolve(fromBase64(snapshot.meta.contents)); }; const restoreSnapshot: VersioningEndpoints["restoreSnapshot"] = async ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b78919732..b44eab9fea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4638,6 +4638,9 @@ importers: hast-util-from-dom: specifier: ^5.0.1 version: 5.0.1 + lib0: + specifier: 0.2.116 + version: 0.2.116 prosemirror-dropcursor: specifier: ^1.8.2 version: 1.8.2 @@ -12975,6 +12978,11 @@ packages: engines: {node: '>=16'} hasBin: true + lib0@0.2.116: + resolution: {integrity: sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==} + engines: {node: '>=16'} + hasBin: true + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -17859,13 +17867,13 @@ snapshots: '@hocuspocus/common@2.15.3': dependencies: - lib0: 0.2.114 + lib0: 0.2.116 '@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: '@hocuspocus/common': 2.15.3 '@lifeomic/attempt': 3.1.0 - lib0: 0.2.114 + lib0: 0.2.116 ws: 8.18.3 y-protocols: 1.0.6(yjs@13.6.27) yjs: 13.6.27 @@ -24599,6 +24607,10 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lib0@0.2.116: + dependencies: + isomorphic.js: 0.2.5 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -28209,7 +28221,7 @@ snapshots: y-indexeddb@9.0.12(yjs@13.6.27): dependencies: - lib0: 0.2.114 + lib0: 0.2.116 yjs: 13.6.27 y-partykit@0.0.25: @@ -28222,7 +28234,7 @@ snapshots: y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): dependencies: - lib0: 0.2.114 + lib0: 0.2.116 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-view: 1.41.4 @@ -28231,7 +28243,7 @@ snapshots: y-protocols@1.0.6(yjs@13.6.27): dependencies: - lib0: 0.2.114 + lib0: 0.2.116 yjs: 13.6.27 y18n@5.0.8: {}