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
new file mode 100644
index 0000000000..d1cc2f0db2
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/.bnexample.json
@@ -0,0 +1,11 @@
+{
+ "playground": true,
+ "docs": true,
+ "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/README.md b/examples/07-collaboration/09-versioning/README.md
new file mode 100644
index 0000000000..528f98165e
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/README.md
@@ -0,0 +1,15 @@
+# Collaborative Editing Features Showcase
+
+In this example, you can play with all of the collaboration features BlockNote has to offer:
+
+**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/index.html b/examples/07-collaboration/09-versioning/index.html
new file mode 100644
index 0000000000..42dc61461a
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Collaborative Editing Features Showcase
+
+
+
+
+
+
+
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..531997eaba
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/package.json
@@ -0,0 +1,34 @@
+{
+ "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",
+ "@floating-ui/react": "^0.27.16",
+ "react-icons": "^5.2.1",
+ "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..d026f29c33
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/src/App.tsx
@@ -0,0 +1,204 @@
+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 { useEffect, 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";
+import { SuggestionActions } from "./SuggestionActions";
+import { SuggestionActionsPopup } from "./SuggestionActionsPopup";
+
+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 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: { color: getRandomColor(), name: activeUser.username },
+ },
+ extensions: [
+ CommentsExtension({ threadStore, resolveUsers }),
+ SuggestionsExtension(),
+ VersioningExtension({
+ endpoints: localStorageEndpoints,
+ fragment: doc.getXmlFragment(),
+ }),
+ ],
+ });
+
+ 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 (
+
+
+ {/* 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,
+ }))}
+ />
+ {activeUser.role === "editor" && (
+ {
+ disableSuggestions();
+ setEditingMode("editing");
+ },
+ isSelected: editingMode === "editing",
+ },
+ {
+ text: "Suggestions",
+ icon: null,
+ onClick: () => {
+ enableSuggestions();
+ setEditingMode("suggestions");
+ },
+ isSelected: editingMode === "suggestions",
+ },
+ ]}
+ />
+ )}
+ {activeUser.role === "editor" &&
+ 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. */}
+ {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/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/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
new file mode 100644
index 0000000000..aa3b2d8f6f
--- /dev/null
+++ b/examples/07-collaboration/09-versioning/src/style.css
@@ -0,0 +1,243 @@
+.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%;
+}
+
+.full-collaboration .editor-layout-wrapper {
+ align-items: center;
+ display: flex;
+ 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%;
+ min-width: 350px;
+ width: 100%;
+}
+
+.full-collaboration .editor-section h1,
+.full-collaboration .sidebar-section h1 {
+ color: var(--bn-colors-menu-text);
+ margin: 0;
+ font-size: 32px;
+}
+
+.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;
+ gap: 10px;
+ height: 100%;
+ overflow: auto;
+}
+
+.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;
+}
+
+.full-collaboration .settings {
+ display: flex;
+ 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) !important;
+ min-width: auto;
+}
+
+.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;
+ gap: 16px;
+ display: flex;
+ overflow: visible;
+ padding: 16px 32px;
+ width: 100%;
+}
+
+.full-collaboration .bn-snapshot-name {
+ background: transparent;
+ border: none;
+ color: var(--bn-colors-menu-text);
+ font-size: 16px;
+ font-weight: 600;
+ padding: 0;
+ width: 100%;
+}
+
+.full-collaboration .bn-snapshot-name:focus {
+ outline: none;
+}
+
+.full-collaboration .bn-snapshot-body {
+ display: flex;
+ flex-direction: column;
+ font-size: 12px;
+ gap: 4px;
+}
+
+.full-collaboration .bn-snapshot-button {
+ background-color: #4da3ff;
+ border: none;
+ border-radius: 4px;
+ color: var(--bn-colors-selected-text);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 0 8px;
+ width: fit-content;
+}
+
+.full-collaboration.dark .bn-snapshot-button {
+ background-color: #0070e8;
+}
+
+.full-collaboration .bn-snapshot-button:hover {
+ background-color: #73b7ff;
+}
+
+.full-collaboration.dark .bn-snapshot-button:hover {
+ background-color: #3785d8;
+}
+
+.full-collaboration .bn-versioning-sidebar .bn-snapshot.selected {
+ background-color: #f5f9fd;
+ border: 2px solid #c2dcf8;
+}
+
+.full-collaboration.dark .bn-versioning-sidebar .bn-snapshot.selected {
+ 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/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/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/package.json b/packages/core/package.json
index 0209577129..be1eb81384 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",
@@ -108,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/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/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/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
new file mode 100644
index 0000000000..e44f03c487
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/Versioning.ts
@@ -0,0 +1,229 @@
+import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
+import * as Y from "yjs";
+
+import {
+ createExtension,
+ createStore,
+ ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import {
+ findTypeInOtherYdoc,
+ ForkYDocExtension,
+} from "../Collaboration/ForkYDoc.js";
+
+import { SuggestionsExtension } from "../Suggestions/Suggestions.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;
+ /**
+ * Additional metadata about the snapshot.
+ */
+ meta: {
+ /**
+ * The user IDs associated with the snapshot.
+ */
+ userIds?: string[];
+ /**
+ * The ID of the previous snapshot that this snapshot was restored from.
+ */
+ restoredFromSnapshotId?: string;
+ /**
+ * 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,
+ /**
+ * 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
+ * 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(
+ ({
+ editor,
+ 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 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) => ({
+ ...state,
+ snapshots,
+ }));
+ };
+
+ const initSnapshots = async () => {
+ await updateSnapshots();
+
+ if (store.state.snapshots.length > 0) {
+ const snapshotContent = await endpoints.fetchSnapshotContent(
+ store.state.snapshots[0].id,
+ );
+
+ applySnapshot(snapshotContent);
+ }
+ };
+
+ const selectSnapshot = async (id: string | undefined) => {
+ store.setState((state) => ({
+ ...state,
+ selectedSnapshotId: id,
+ }));
+
+ if (id === undefined) {
+ // 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();
+ 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,
+ mount: () => {
+ initSnapshots();
+ },
+ 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];
+ },
+ canRestoreSnapshot: endpoints.restoreSnapshot !== undefined,
+ restoreSnapshot: endpoints.restoreSnapshot
+ ? async (id: string): Promise => {
+ selectSnapshot(undefined);
+
+ const snapshotContent = await endpoints.restoreSnapshot!(
+ fragment,
+ id,
+ );
+ applySnapshot(snapshotContent);
+ await updateSnapshots();
+
+ return snapshotContent;
+ }
+ : undefined,
+ canUpdateSnapshotName: endpoints.updateSnapshotName !== undefined,
+ updateSnapshotName: endpoints.updateSnapshotName
+ ? async (id: string, name?: string): Promise => {
+ await endpoints.updateSnapshotName!(id, name);
+ await updateSnapshots();
+ }
+ : undefined,
+
+ 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/Versioning/localStorageEndpoints.ts b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts
new file mode 100644
index 0000000000..7cce991506
--- /dev/null
+++ b/packages/core/src/extensions/Versioning/localStorageEndpoints.ts
@@ -0,0 +1,102 @@
+import { v4 } from "uuid";
+import * as Y from "yjs";
+import { toBase64, fromBase64 } from "lib0/buffer";
+
+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,
+ restoredFromSnapshotId?: string,
+): Promise => {
+ const snapshot = {
+ id: v4(),
+ name,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ meta: {
+ restoredFromSnapshotId,
+ userIds: ["User1"],
+ contents: toBase64(Y.encodeStateAsUpdateV2(fragment.doc!)),
+ },
+ } 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(fromBase64(snapshot.meta.contents));
+ };
+
+const restoreSnapshot: VersioningEndpoints["restoreSnapshot"] = async (
+ fragment,
+ id,
+) => {
+ // take a snapshot of the current document
+ await createSnapshot(fragment, "Backup");
+
+ // 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);
+
+ // 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 what the new state should be
+ return snapshotContent;
+};
+
+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;
+ snapshot.updatedAt = Date.now();
+
+ 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..a4dd6a8104 100644
--- a/packages/core/src/extensions/index.ts
+++ b/packages/core/src/extensions/index.ts
@@ -20,5 +20,8 @@ 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";
+export * from "./Versioning/localStorageEndpoints.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/packages/react/src/components/Versioning/CurrentSnapshot.tsx b/packages/react/src/components/Versioning/CurrentSnapshot.tsx
new file mode 100644
index 0000000000..d248efe369
--- /dev/null
+++ b/packages/react/src/components/Versioning/CurrentSnapshot.tsx
@@ -0,0 +1,47 @@
+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
+ )}
+
+
{
+ // Prevent event bubbling to avoid calling `selectSnapshot`.
+ event.preventDefault();
+ event.stopPropagation();
+
+ createSnapshot(
+ snapshotName !== "Current Version" ? snapshotName : undefined,
+ );
+ setSnapshotName("Current Version");
+ }}
+ >
+ Save
+
+
+ );
+};
diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx
new file mode 100644
index 0000000000..9f98d523aa
--- /dev/null
+++ b/packages/react/src/components/Versioning/Snapshot.tsx
@@ -0,0 +1,86 @@
+import {
+ VersioningExtension,
+ VersionSnapshot,
+} from "@blocknote/core/extensions";
+
+import { useExtension, useExtensionState } from "../../hooks/useExtension.js";
+import { dateToString } from "./dateToString.js";
+import { useState } from "react";
+
+export const Snapshot = ({ snapshot }: { snapshot: VersionSnapshot }) => {
+ const {
+ canRestoreSnapshot,
+ restoreSnapshot,
+ canUpdateSnapshotName,
+ updateSnapshotName,
+ selectSnapshot,
+ } = useExtension(VersioningExtension);
+ const selected = useExtensionState(VersioningExtension, {
+ selector: (state) => state.selectedSnapshotId === snapshot.id,
+ });
+ const revertedSnapshot = useExtensionState(VersioningExtension, {
+ selector: (state) =>
+ snapshot?.meta.restoredFromSnapshotId !== undefined
+ ? state.snapshots.find(
+ (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;
+ }
+
+ return (
+ selectSnapshot(snapshot.id)}
+ >
+
+
setSnapshotName(e.target.value)}
+ onBlur={() =>
+ updateSnapshotName?.(
+ snapshot.id,
+ snapshotName === dateString ? undefined : snapshotName,
+ )
+ }
+ />
+ {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(", ")}`}
+ )}
+
+ {canRestoreSnapshot && (
+
{
+ // Prevent event bubbling to avoid calling `selectSnapshot`.
+ event.preventDefault();
+ event.stopPropagation();
+
+ restoreSnapshot?.(snapshot.id);
+ }}
+ >
+ Restore
+
+ )}
+
+ );
+};
diff --git a/packages/react/src/components/Versioning/VersioningSidebar.tsx b/packages/react/src/components/Versioning/VersioningSidebar.tsx
new file mode 100644
index 0000000000..00af0a8891
--- /dev/null
+++ b/packages/react/src/components/Versioning/VersioningSidebar.tsx
@@ -0,0 +1,22 @@
+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 = (props: { filter?: "named" | "all" }) => {
+ const { snapshots } = useExtensionState(VersioningExtension);
+
+ return (
+
+
+ {snapshots
+ .filter((snapshot) =>
+ props.filter === "named" ? snapshot.name !== undefined : true,
+ )
+ .map((snapshot) => {
+ 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..a3936a2947 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1607,6 +1607,32 @@
"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": {
+ "@floating-ui/react": "^0.27.16",
+ "react-icons": "^5.2.1",
+ "yjs": "^13.6.27"
+ } as any
+ },
+ "title": "Collaborative Editing Features Showcase",
+ "group": {
+ "pathFromRoot": "examples/07-collaboration",
+ "slug": "collaboration"
+ },
+ "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)"
}
]
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23b7b4b52b..b44eab9fea 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)
@@ -3854,6 +3857,61 @@ 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
+ '@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)
+ '@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)
+ react-icons:
+ specifier: ^5.2.1
+ version: 5.5.0(react@19.2.1)
+ 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':
@@ -4523,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)
@@ -4577,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
@@ -12914,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==}
@@ -17798,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
@@ -24538,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
@@ -28148,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:
@@ -28161,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
@@ -28170,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: {}