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
+ )} +
+ +
+ ); +}; 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 && ( + + )} +
+ ); +}; 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: {}