diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9907ba29ec..c380129657 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ jobs: run: vp run -r build - name: Lint packages - run: vp lint + run: vp run lint - name: Run unit tests # Always execute tests: only build results are reusable across CI runs. diff --git a/docs/app/demo/_components/DemoEditor.tsx b/docs/app/demo/_components/DemoEditor.tsx index c4e690582c..54189ad635 100644 --- a/docs/app/demo/_components/DemoEditor.tsx +++ b/docs/app/demo/_components/DemoEditor.tsx @@ -17,11 +17,11 @@ import * as locales from "@blocknote/core/locales"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { - BlockNoteViewEditor, FloatingComposerController, FloatingThreadController, FormattingToolbar, FormattingToolbarController, + RenderInPortalElement, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, @@ -198,6 +198,10 @@ function DemoEditorInner({ } return window.innerWidth >= 768; }); + // The element in the layout that the comments sidebar is rendered into. + const [threadsElement, setThreadsElement] = useState( + null, + ); const { doc, provider } = useMemo(() => { const doc = new Y.Doc(); @@ -404,47 +408,48 @@ function DemoEditorInner({
- - - - - -
-
-
- -
+
+
+ + + + {!sidebarOpen && } -
- -
-
- Comments -
-
- -
-
+ {/* `ThreadsSidebar` needs the editor's context, so it's rendered + here, but `RenderInPortalElement` places it in the sidebar + panel next to the editor. */} + {threadsElement && ( + + + + )} +
- +
+ +
+
+ Comments +
+
+
); diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 32f341e5d8..b28e931cd3 100644 --- a/docs/content/docs/features/blocks/code-blocks.mdx +++ b/docs/content/docs/features/blocks/code-blocks.mdx @@ -95,7 +95,7 @@ That's all — see it in action in [this example](/examples/theming/code-block). Under the hood, highlighting is split into two parts, which you can also wire up yourself for full control over the bundle: -- The `SyntaxHighlightingExtension` (from `@blocknote/core`) provides the highlighter — a [Shiki](https://shiki.style) instance with your choice of languages, themes, and engine: +- The `SyntaxHighlightingExtension` (from `@blocknote/core/extensions`) provides the highlighter — a [Shiki](https://shiki.style) instance with your choice of languages, themes, and engine: ```ts type SyntaxHighlightingOptions = { @@ -125,7 +125,7 @@ npx shiki-codegen --langs javascript,typescript,vue --themes light-plus,dark-plu This generates a `shiki.bundle.ts` file that you use to create the extension — added to the editor exactly like the pre-configured one above, alongside a `createCodeBlockSpec` configured with your matching languages: ```ts -import { SyntaxHighlightingExtension } from "@blocknote/core"; +import { SyntaxHighlightingExtension } from "@blocknote/core/extensions"; import { createHighlighter } from "./shiki.bundle.js"; const syntaxHighlighter = SyntaxHighlightingExtension({ diff --git a/docs/content/docs/features/collaboration/comments.mdx b/docs/content/docs/features/collaboration/comments.mdx index 7d6930c5f2..7dfbeae0f9 100644 --- a/docs/content/docs/features/collaboration/comments.mdx +++ b/docs/content/docs/features/collaboration/comments.mdx @@ -164,7 +164,7 @@ BlockNote also offers a different way of viewing and interacting with comments, -The only requirement for `ThreadsSidebar` is that it should be placed somewhere within your `BlockNoteView`, other than that you can position and style it however you want. +The only requirement for `ThreadsSidebar` is that it should be placed somewhere within your `BlockNoteView`, other than that you can position and style it however you want. To show it elsewhere in your application's layout, use [`RenderInPortalElement`](/docs/react/components#placing-blocknote-ui-in-your-own-layout). `ThreadsSidebar` also takes 2 props: diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index 985e00df4b..149d386bba 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -60,7 +60,7 @@ Each UI element is backed by an [extension](/docs/features/extensions). You can While it's up to you to decide how you want the elements to be rendered, the store lets you react to state changes so you can update the visibility, position, and contents of your elements: ```typescript -import { SideMenuExtension } from "@blocknote/core"; +import { SideMenuExtension } from "@blocknote/core/extensions"; const extension = editor.getExtension(SideMenuExtension)!; diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index 79dd203ddb..3234b09c31 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -36,3 +36,33 @@ Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu The [mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar) always portals to `document.body`, including its menus and popovers. It does not use the configured formatting-toolbar or default target. When a target sits outside the editor's DOM (like `document.body`), BlockNote automatically renders a themed wrapper element inside it, so floating UI keeps the editor's styling and theming wherever it's portalled. + +### Placing BlockNote UI in your own layout + +Some components, like the [comments sidebar](/docs/features/collaboration/comments#sidebar-view), aren't floating but still need the editor's context, so they must be rendered inside `BlockNoteView`. To show one somewhere else in your application's layout, render it inside `BlockNoteView` wrapped in `RenderInPortalElement`, and pass the element it should appear in: + +```tsx +function App() { + const editor = useCreateBlockNote(/* ... */); + // The element in your layout that the sidebar is rendered into. + const [sidebarElement, setSidebarElement] = useState( + null, + ); + + return ( +
+ + + {sidebarElement && ( + + + + )} + +
+ ); +} +``` + +The children are rendered inside a themed wrapper element in `target`, so they keep the editor's styling, and focus inside them counts as focus within the editor. diff --git a/docs/package.json b/docs/package.json index 2fdeefb332..ebc1f18203 100644 --- a/docs/package.json +++ b/docs/package.json @@ -73,10 +73,10 @@ "@uppy/xhr-upload": "^3.4.0", "@vercel/analytics": "^1.6.1", "@y-sweet/react": "^0.6.3", - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-14", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", + "@y/y": "^14.0.0-rc.26", "ai": "^6.0.5", "better-auth": "~1.4.15", "better-sqlite3": "^12.6.2", @@ -89,7 +89,7 @@ "fumadocs-typescript": "^5.1.1", "fumadocs-ui": "npm:@fumadocs/base-ui@16.5.0", "katex": "^0.18.9", - "lib0": "^1.0.0-rc.34", + "lib0": "1.0.0-rc.36", "lucide-react": "^0.562.0", "mathjax-full": "^3.2.2", "mermaid": "^11.0.0", @@ -110,10 +110,11 @@ "typescript-5": "npm:typescript@^5.9.3", "y-partykit": "^0.0.25", "y-websocket": "^2.1.0", - "yjs": "^13.6.27", + "yjs": "^13.6.33", "zod": "^4.3.5", "@blocknote/xl-typst-exporter": "workspace:*", - "@blocknote/xl-typst-compiler": "workspace:*" + "@blocknote/xl-typst-compiler": "workspace:*", + "y-prosemirror": "^1.3.7" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/examples/03-ui-components/18-drag-n-drop/README.md b/examples/03-ui-components/18-drag-n-drop/README.md index 6746cf86a3..8a6f852061 100644 --- a/examples/03-ui-components/18-drag-n-drop/README.md +++ b/examples/03-ui-components/18-drag-n-drop/README.md @@ -20,7 +20,7 @@ The exclusion check works by traversing up the DOM tree from the drag event targ ### Import the constant: ```tsx -import { DRAG_EXCLUSION_CLASSNAME } from "@blocknote/core"; +import { DRAG_EXCLUSION_CLASSNAME } from "@blocknote/core/extensions"; ``` ### Apply it to your custom drag area: diff --git a/examples/04-theming/07-custom-code-block/src/App.tsx b/examples/04-theming/07-custom-code-block/src/App.tsx index 387d3463c8..6ae46c4723 100644 --- a/examples/04-theming/07-custom-code-block/src/App.tsx +++ b/examples/04-theming/07-custom-code-block/src/App.tsx @@ -1,8 +1,5 @@ -import { - BlockNoteSchema, - createCodeBlockSpec, - SyntaxHighlightingExtension, -} from "@blocknote/core"; +import { BlockNoteSchema, createCodeBlockSpec } from "@blocknote/core"; +import { SyntaxHighlightingExtension } from "@blocknote/core/extensions"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; diff --git a/examples/07-collaboration/01-partykit/.bnexample.json b/examples/07-collaboration/01-partykit/.bnexample.json index 87250048fe..6bea9a0457 100644 --- a/examples/07-collaboration/01-partykit/.bnexample.json +++ b/examples/07-collaboration/01-partykit/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Advanced", "Saving/Loading", "Collaboration"], "dependencies": { "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/07-collaboration/01-partykit/package.json b/examples/07-collaboration/01-partykit/package.json index b22a23baa0..a8be767099 100644 --- a/examples/07-collaboration/01-partykit/package.json +++ b/examples/07-collaboration/01-partykit/package.json @@ -21,7 +21,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/02-liveblocks/.bnexample.json b/examples/07-collaboration/02-liveblocks/.bnexample.json index ff9df216ca..9ad22de9a7 100644 --- a/examples/07-collaboration/02-liveblocks/.bnexample.json +++ b/examples/07-collaboration/02-liveblocks/.bnexample.json @@ -9,6 +9,6 @@ "@liveblocks/react-blocknote": "^3.19.5", "@liveblocks/react-tiptap": "^3.19.5", "@liveblocks/react-ui": "^3.19.5", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/07-collaboration/02-liveblocks/package.json b/examples/07-collaboration/02-liveblocks/package.json index aa06459e0a..8bdb7a0013 100644 --- a/examples/07-collaboration/02-liveblocks/package.json +++ b/examples/07-collaboration/02-liveblocks/package.json @@ -25,7 +25,7 @@ "@liveblocks/react-blocknote": "^3.19.5", "@liveblocks/react-tiptap": "^3.19.5", "@liveblocks/react-ui": "^3.19.5", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/06-comments-with-sidebar/.bnexample.json b/examples/07-collaboration/06-comments-with-sidebar/.bnexample.json index ff82fe290f..cc1082289e 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/.bnexample.json +++ b/examples/07-collaboration/06-comments-with-sidebar/.bnexample.json @@ -5,7 +5,7 @@ "tags": ["Advanced", "Comments", "Collaboration"], "dependencies": { "y-partykit": "^0.0.25", - "yjs": "^13.6.27", + "yjs": "^13.6.33", "@mantine/core": "^9.0.2" } } diff --git a/examples/07-collaboration/06-comments-with-sidebar/package.json b/examples/07-collaboration/06-comments-with-sidebar/package.json index 4319a3e3a9..db0b53e106 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/package.json +++ b/examples/07-collaboration/06-comments-with-sidebar/package.json @@ -21,7 +21,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx index 66d0d43ad3..f276ed098a 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx @@ -9,8 +9,8 @@ import { withCollaboration, YjsThreadStore } from "@blocknote/core/yjs"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { - BlockNoteViewEditor, FloatingComposerController, + RenderInPortalElement, ThreadsSidebar, useCreateBlockNote, } from "@blocknote/react"; @@ -94,107 +94,82 @@ export default function App() { [activeUser, threadStore], ); + // The element in your layout that the comments sidebar is rendered into. + const [sidebarElement, setSidebarElement] = useState( + null, + ); + + // The page layout is your application's own. BlockNote only renders the + // editor (`BlockNoteView`) and, via `RenderInPortalElement`, the sidebar. 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. */} +
-
+

Editor

({ - text: `${user.username} (${ + value={activeUser.id} + options={HARDCODED_USERS.map((user) => ({ + value: user.id, + label: `${user.username} (${ user.role === "editor" ? "Editor" : "Commenter" })`, - icon: null, - onClick: () => { - setActiveUser(user); - }, - isSelected: user.id === activeUser.id, }))} + onChange={(id) => { + const user = HARDCODED_USERS.find((user) => user.id === id); + if (user) { + setActiveUser(user); + } + }} />
- {/* 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. */} - -
+ + {/* `comments={false}` also removes the floating composer, which + creates new threads, so we add it back. */} + + {/* `ThreadsSidebar` needs the editor's context, so it's rendered + inside `BlockNoteView`, but `RenderInPortalElement` places it in the + sidebar element of the layout below. */} + {sidebarElement && ( + + + + )} + +
- {/* We also place the `ThreadsSidebar` component in its own section, - along with settings for filtering and sorting. */} -
+
- +
+ +
); } diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx index b5394fe89e..d5baa743fa 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx @@ -1,33 +1,32 @@ -import { - ComponentProps, - useComponentsContext, - usePortalElement, -} 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: { +// A plain select with a label. This is application UI, so it's built from your +// own elements (or your app's component library) rather than BlockNote's +// components, and can live anywhere in your layout. +export function SettingsSelect(props: { label: string; - items: ComponentProps["FormattingToolbar"]["Select"]["items"]; -}) => { - const Components = useComponentsContext()!; - // The select's dropdown portals into the editor's portal element, which keeps - // it themed and clear of any overflow clipping. The prop is required, so it - // can't be left out by accident. - const portalElement = usePortalElement(); - + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { return ( -
- -

{props.label + ":"}

- -
-
+ ); -}; +} diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/style.css b/examples/07-collaboration/06-comments-with-sidebar/src/style.css index f903d52e1b..1171228259 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/style.css +++ b/examples/07-collaboration/06-comments-with-sidebar/src/style.css @@ -1,13 +1,23 @@ +/* The application's own layout and styling. BlockNote's `--bn-*` variables + only exist inside BlockNote's (themed) elements, so the app chrome uses its + own colors. */ .sidebar-comments-main-container { - background-color: var(--bn-colors-disabled-background); + background-color: #f0f0f0; + color: #3f3f3f; display: flex; gap: 10px; height: 100%; - max-width: none; padding: 10px; width: 100%; } +@media (prefers-color-scheme: dark) { + .sidebar-comments-main-container { + background-color: #121212; + color: #cfcfcf; + } +} + .sidebar-comments-main-container .editor-layout-wrapper { display: flex; flex: 2; @@ -15,11 +25,14 @@ width: 0; } +.sidebar-comments-main-container .editor-section { + flex: 1; + max-width: 700px; +} + .sidebar-comments-main-container .editor-section, -.threads-sidebar-section { - border-radius: var(--bn-border-radius-large); +.sidebar-comments-main-container .threads-sidebar-section { display: flex; - flex: 1; flex-direction: column; gap: 10px; max-height: 100%; @@ -27,25 +40,15 @@ width: 0; } -.sidebar-comments-main-container .editor-section h1, -.threads-sidebar-section h1 { - color: var(--bn-colors-menu-text); - margin: 0; - font-size: 32px; -} - -.sidebar-comments-main-container .bn-editor, -.bn-threads-sidebar { - border-radius: var(--bn-border-radius-medium); - display: flex; - flex-direction: column; - gap: 10px; - height: 100%; - overflow: auto; +/* A fixed-width column; the editor column takes the remaining space. */ +.sidebar-comments-main-container .threads-sidebar-section { + flex: none; + width: 360px; } -.sidebar-comments-main-container .editor-section { - max-width: 700px; +.sidebar-comments-main-container h1 { + font-size: 32px; + margin: 0; } .sidebar-comments-main-container .settings { @@ -55,19 +58,61 @@ } .sidebar-comments-main-container .settings-select { + align-items: center; display: flex; - gap: 10px; + font-size: 12px; + font-weight: 600; + gap: 8px; } -.sidebar-comments-main-container .settings-select .bn-toolbar { - align-items: center; - box-shadow: none; +.sidebar-comments-main-container .settings-select select { + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + color: inherit; + font: inherit; + font-weight: 500; + padding: 6px 8px; +} + +@media (prefers-color-scheme: dark) { + .sidebar-comments-main-container .settings-select select { + background-color: #1f1f1f; + border-color: #333333; + } } -.sidebar-comments-main-container .settings-select h2 { - color: var(--bn-colors-menu-text); +/* The editor fills the rest of its section and scrolls on its own. */ +.sidebar-comments-main-container .editor-section .bn-container { + display: flex; + flex: 1; + flex-direction: column; margin: 0; - font-size: 12px; - line-height: 12px; - padding-left: 14px; + max-width: none; + min-height: 0; + padding: 0; +} + +.sidebar-comments-main-container .editor-section .bn-editor { + border-radius: 8px; + flex: 1; + overflow: auto; +} + +/* The slot the sidebar is rendered into, and the themed root BlockNote creates + inside it. */ +.sidebar-comments-main-container .threads-sidebar-slot, +.sidebar-comments-main-container .threads-sidebar-slot > .bn-root { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.bn-threads-sidebar { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + overflow: auto; } diff --git a/examples/07-collaboration/07-ghost-writer/.bnexample.json b/examples/07-collaboration/07-ghost-writer/.bnexample.json index 2c30ef42bd..f12df6cb84 100644 --- a/examples/07-collaboration/07-ghost-writer/.bnexample.json +++ b/examples/07-collaboration/07-ghost-writer/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/07-collaboration/07-ghost-writer/package.json b/examples/07-collaboration/07-ghost-writer/package.json index 58cc65914b..e11d99e720 100644 --- a/examples/07-collaboration/07-ghost-writer/package.json +++ b/examples/07-collaboration/07-ghost-writer/package.json @@ -21,7 +21,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/08-forking/.bnexample.json b/examples/07-collaboration/08-forking/.bnexample.json index 2c30ef42bd..f12df6cb84 100644 --- a/examples/07-collaboration/08-forking/.bnexample.json +++ b/examples/07-collaboration/08-forking/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/07-collaboration/08-forking/package.json b/examples/07-collaboration/08-forking/package.json index d9d2ca9461..e887e5b222 100644 --- a/examples/07-collaboration/08-forking/package.json +++ b/examples/07-collaboration/08-forking/package.json @@ -21,7 +21,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/09-comments-testing/.bnexample.json b/examples/07-collaboration/09-comments-testing/.bnexample.json index 5d7d986420..763151c7e9 100644 --- a/examples/07-collaboration/09-comments-testing/.bnexample.json +++ b/examples/07-collaboration/09-comments-testing/.bnexample.json @@ -4,6 +4,6 @@ "author": "matthewlipski", "tags": ["Advanced", "Comments", "Testing"], "dependencies": { - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/07-collaboration/09-comments-testing/package.json b/examples/07-collaboration/09-comments-testing/package.json index 0974a4ba35..34df8487c6 100644 --- a/examples/07-collaboration/09-comments-testing/package.json +++ b/examples/07-collaboration/09-comments-testing/package.json @@ -20,7 +20,7 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json b/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json index f19d4b6251..b5b085ad97 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json +++ b/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json @@ -5,8 +5,8 @@ "tags": ["Advanced", "Saving/Loading", "Collaboration"], "dependencies": { "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-14", "@y/websocket": "^4.0.0-rc.2" } } diff --git a/examples/07-collaboration/10-suggestion-multi-editor/package.json b/examples/07-collaboration/10-suggestion-multi-editor/package.json index 683ec57c6d..3d145a8bda 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/package.json +++ b/examples/07-collaboration/10-suggestion-multi-editor/package.json @@ -21,8 +21,8 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-14", "@y/websocket": "^4.0.0-rc.2" }, "devDependencies": { diff --git a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx index 5725db5d8f..4a25b3a3fd 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx +++ b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx @@ -4,8 +4,9 @@ import "@blocknote/mantine/style.css"; import { BlockNoteView } from "@blocknote/mantine"; import { useCreateBlockNote } from "@blocknote/react"; import { Awareness } from "@y/protocols/awareness"; -import { withCollaboration } from "@blocknote/core/y"; +import { SuggestionsExtension, withCollaboration } from "@blocknote/core/y"; import * as Y from "@y/y"; +import { useEffect } from "react"; const doc = new Y.Doc(); const provider = { @@ -25,7 +26,7 @@ provider2.awareness.setLocalStateField("user", { color: "#6eeb83", }); -const attrs = new Y.Attributions(); +const attrs = Y.createContentMap(); // Batch timestamps: reuse the same timestamp for edits from the same user // within a 10-second window of inactivity. @@ -58,46 +59,41 @@ function getBatchedTimestamp(userName: string): number { return batchTimestamps.get(userName)!; } -// Track attributions per user for each doc +// Record attribution before observers render local changes. The update event is +// too late for the local suggestion marks, though remote peers see the metadata. function trackAttributions( trackedDoc: Y.Doc, userName: string, - attributions: Y.Attributions, + attributions: Y.ContentMap, ) { - trackedDoc.on( - "update", - ( - update: Uint8Array, - _origin: unknown, - _ydoc: Y.Doc, - tr: { local: boolean }, - ) => { - if (!tr.local) return; - const contentIds = Y.createContentIdsFromUpdate(update); - const timestamp = getBatchedTimestamp(userName); - Y.insertIntoIdMap( - attributions.inserts, - Y.createIdMapFromIdSet(contentIds.inserts, [ - Y.createContentAttribute("insert", userName), - Y.createContentAttribute("insertAt", timestamp), - ]), - ); - Y.insertIntoIdMap( - attributions.deletes, - Y.createIdMapFromIdSet(contentIds.deletes, [ - Y.createContentAttribute("delete", userName), - Y.createContentAttribute("deleteAt", timestamp), - ]), - ); - }, - ); + trackedDoc.on("beforeObserverCalls", (tr) => { + if (!tr.local) return; + const timestamp = getBatchedTimestamp(userName); + Y.insertIntoIdMap( + attributions.inserts, + Y.createIdMapFromIdSet(tr.insertSet, [ + Y.createContentAttribute("insert", userName), + Y.createContentAttribute("insertAt", timestamp), + ]), + ); + Y.insertIntoIdMap( + attributions.deletes, + Y.createIdMapFromIdSet(tr.deleteSet, [ + Y.createContentAttribute("delete", userName), + Y.createContentAttribute("deleteAt", timestamp), + ]), + ); + }); } // Track local changes on each doc with a distinct user name trackAttributions(doc, "Alice", attrs); trackAttributions(doc2, "Bob", attrs); +// Register attribution tracking before the renderers' beforeObserverCalls +// listeners so they see the local author's metadata on their first render. const suggestingDoc = new Y.Doc({ isSuggestionDoc: true }); +trackAttributions(suggestingDoc, "Charlie", attrs); const suggestingProvider = { awareness: new Awareness(suggestingDoc), }; @@ -105,10 +101,13 @@ suggestingProvider.awareness.setLocalStateField("user", { name: "Charlie", color: "#ffbc42", }); -const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, { attrs }); +const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, { + attributions: attrs, +}); suggestingRenderer.suggestionMode = false; const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true }); +trackAttributions(suggestionModeDoc, "Debbie", attrs); const suggestionModeProvider = { awareness: new Awareness(suggestionModeDoc), }; @@ -117,14 +116,10 @@ suggestionModeProvider.awareness.setLocalStateField("user", { color: "#ee6352", }); const suggestionModeRenderer = Y.createDiffRenderer(doc, suggestionModeDoc, { - attrs, + attributions: attrs, }); suggestionModeRenderer.suggestionMode = true; -// Track local changes on suggestion docs with distinct user names -trackAttributions(suggestingDoc, "Charlie", attrs); -trackAttributions(suggestionModeDoc, "Debbie", attrs); - // Function to sync two documents function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) { const update = Y.encodeStateAsUpdate(sourceDoc); @@ -151,13 +146,17 @@ setupTwoWaySync(suggestingDoc, suggestionModeDoc); function Editor({ fragment, provider, - renderer, + suggestions, userName, userColor, }: { - fragment: Y.Type; + fragment: Y.Node; provider: { awareness?: Awareness }; - renderer?: Y.DiffRenderer; + suggestions?: { + doc: Y.Doc; + renderer: Y.DiffRenderer; + mode: "view" | "edit"; + }; userName: string; userColor: string; }) { @@ -166,13 +165,29 @@ function Editor({ collaboration: { fragment, provider, - renderer, + suggestionDoc: suggestions?.doc, + renderer: suggestions?.renderer, user: { name: userName, color: userColor }, }, }), ); - return ; + useEffect(() => { + if (!suggestions) { + return; + } + + const extension = editor.getExtension(SuggestionsExtension)!; + if (suggestions.mode === "edit") { + extension.enableSuggestions(); + } else { + extension.viewSuggestions(); + } + }, [editor, suggestions]); + + return ( + + ); } export default function App() { @@ -217,9 +232,13 @@ export default function App() {
View Suggestions (Charlie) @@ -227,9 +246,13 @@ export default function App() {
Suggestion Mode (Debbie) diff --git a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json index b15e25b84b..fc23b5dcde 100644 --- a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json +++ b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json @@ -1,11 +1,15 @@ { "playground": true, - "docs": true, + "docs": false, "author": "yousefed", "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { + "@y/prosemirror": "^2.0.0-14", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.26", "y-websocket": "^2.1.0", - "yjs": "^13.6.27", - "lib0": "^0.2.119" + "yjs": "^13.6.33", + "lib0": "^0.2.119", + "y-prosemirror": "^1.3.7" } } diff --git a/examples/07-collaboration/11-versioning-yjs13/README.md b/examples/07-collaboration/11-versioning-yjs13/README.md index 3482e62a55..e91e81d93e 100644 --- a/examples/07-collaboration/11-versioning-yjs13/README.md +++ b/examples/07-collaboration/11-versioning-yjs13/README.md @@ -1,8 +1,8 @@ -# Local Storage Versioning (yjs v13) +# Local Storage Versioning (Yjs v13, Experimental) -This example shows how to use the `VersioningExtension` with collaborative editing using `yjs` (v13). Snapshots are stored in localStorage using Yjs state updates. +This experimental playground example shows how to use `YjsVersioningExtension` with collaborative editing using Yjs v13. Snapshots are stored in localStorage using Yjs state updates. -**Try it out:** Edit the document, then click the "Version History" button to open the sidebar. From there you can save snapshots, preview older versions, rename them, and restore them. +The sidebar opens on a document with a few versions already in its history. You can preview, compare, rename, and restore a version. `DiffVersioningExtension` highlights insertions and deletions when comparing versions. It uses Yjs v14 internally for rendering, while the live collaborative document stays on Yjs v13. Diffs show content changes, not their original authors. Restoring replaces the live document content and saves the previous content as a "Backup" version. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button and name the current version to save it. **Relevant Docs:** diff --git a/examples/07-collaboration/11-versioning-yjs13/index.html b/examples/07-collaboration/11-versioning-yjs13/index.html index 2e6c9c9afa..14c9a7b48a 100644 --- a/examples/07-collaboration/11-versioning-yjs13/index.html +++ b/examples/07-collaboration/11-versioning-yjs13/index.html @@ -5,7 +5,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> - Local Storage Versioning (yjs v13) + Local Storage Versioning (Yjs v13, Experimental) diff --git a/examples/07-collaboration/11-versioning-yjs13/package.json b/examples/07-collaboration/11-versioning-yjs13/package.json index d3d73b0261..cb1c07ec92 100644 --- a/examples/07-collaboration/11-versioning-yjs13/package.json +++ b/examples/07-collaboration/11-versioning-yjs13/package.json @@ -20,9 +20,13 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "@y/prosemirror": "^2.0.0-14", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.26", "y-websocket": "^2.1.0", - "yjs": "^13.6.27", - "lib0": "^0.2.119" + "yjs": "^13.6.33", + "lib0": "^0.2.119", + "y-prosemirror": "^1.3.7" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx index 015b90c6ec..16623ca702 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx +++ b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx @@ -1,43 +1,62 @@ import "@blocknote/core/fonts/inter.css"; -import { withCollaboration } from "@blocknote/core/yjs"; -import { VersioningExtension } from "@blocknote/core/extensions"; -import { createYjsVersioningAdapter } from "@blocknote/core/yjs"; -import { localStorageEndpoints } from "./localStorageEndpoints"; +import { DiffVersioningExtension } from "@blocknote/core/y"; +import { withCollaboration, YjsVersioningExtension } from "@blocknote/core/yjs"; +import type { VersioningController } from "@blocknote/core/extensions"; import { - BlockNoteViewEditor, - useCreateBlockNote, - useExtensionState, -} from "@blocknote/react"; + hasStoredVersions, + createLocalStorageVersioningEndpoints, + storeVersions, +} from "./localStorageEndpoints"; +import { RenderInPortalElement, useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useState } from "react"; import * as Y from "yjs"; import { WebsocketProvider } from "y-websocket"; import { toBase64, fromBase64 } from "lib0/buffer"; -import { VersionHistorySidebar } from "./VersionHistorySidebar"; +import { VersioningSidebar } from "@blocknote/react/versioning"; +import { + blocksToUpdate, + DAY_MS, + LIVE_DOCUMENT, + SAMPLE_HISTORY, +} from "./sampleVersions"; import "./style.css"; const roomName = "blocknote-versioning-yjs-example"; +const FRAGMENT_NAME = "document-store"; // localStorage key for the live ("current version") document. Snapshots are // persisted separately by `localStorageEndpoints`; this keeps the live doc // itself across refreshes since the demo has no server-side persistence. const DOC_STORAGE_KEY = "blocknote-versioning-yjs-current-doc"; const doc = new Y.Doc(); -const fragment = doc.getXmlFragment("document-store"); +const fragment = doc.getXmlFragment(FRAGMENT_NAME); + +// Persist the full document state on every change. +doc.on("update", () => { + localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc))); +}); // Restore the persisted live document before the editor is created, so it // adopts the stored content instead of starting empty. const persistedDoc = localStorage.getItem(DOC_STORAGE_KEY); if (persistedDoc) { Y.applyUpdate(doc, fromBase64(persistedDoc)); +} else if (!hasStoredVersions()) { + // First visit: seed a few named versions so the history has something to + // show, and open on the newest state of the same document. + storeVersions( + SAMPLE_HISTORY.map((version) => ({ + name: version.name, + createdAt: Date.now() - version.daysAgo * DAY_MS, + content: blocksToUpdate(version.blocks, FRAGMENT_NAME), + })), + ); + Y.applyUpdate(doc, blocksToUpdate(LIVE_DOCUMENT, FRAGMENT_NAME)); } -// Persist the full document state on every change. -doc.on("update", () => { - localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc))); -}); - const provider = new WebsocketProvider( "wss://demos.yjs.dev/ws", roomName, @@ -55,35 +74,40 @@ export default function App() { user: { color: "#ff0000", name: "User", id: "user" }, }, extensions: [ - // The v13 CollaborationExtension does not wire up versioning - // automatically, so we add VersioningExtension manually and use - // createYjsVersioningAdapter to bridge the Yjs v13 preview logic. - VersioningExtension((editor) => ({ - ...createYjsVersioningAdapter(editor, { fragment } as any), - endpoints: localStorageEndpoints, - })), + DiffVersioningExtension(), + YjsVersioningExtension({ + storage: createLocalStorageVersioningEndpoints(fragment), + }), ], }), ); - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { - editor, - }); + const [showSidebar, setShowSidebar] = useState(true); + const [sidebarPanel, setSidebarPanel] = useState(null); return ( -
- -
-
- -
- -
+
+ {/* No `editable` prop: the sidebar makes the editor read-only for as long + as it's open, and restores it on close. */} + + {!showSidebar && ( + + )} + {showSidebar && sidebarPanel && ( + + setShowSidebar(false)} /> + + )} + {showSidebar &&
}
); } diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx deleted file mode 100644 index b5394fe89e..0000000000 --- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { - ComponentProps, - useComponentsContext, - usePortalElement, -} 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()!; - // The select's dropdown portals into the editor's portal element, which keeps - // it themed and clear of any overflow clipping. The prop is required, so it - // can't be left out by accident. - const portalElement = usePortalElement(); - - return ( -
- -

{props.label + ":"}

- -
-
- ); -}; diff --git a/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx b/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx deleted file mode 100644 index a37cd3b31b..0000000000 --- a/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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/11-versioning-yjs13/src/localStorageEndpoints.ts b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts index d1e6a187af..40b7859c96 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts +++ b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts @@ -1,12 +1,28 @@ import * as Y from "yjs"; import { toBase64, fromBase64 } from "lib0/buffer"; -import { - CURRENT_VERSION_ID, - sortSnapshotsNewestFirst, - type VersioningEndpoints, - type VersionSnapshot, +import type { + VersionStorage, + VersionSnapshot, } from "@blocknote/core/extensions"; +import { findTypeInOtherYdoc } from "@blocknote/core/yjs"; + +/** Restore into the live fragment, never the fork currently displayed. */ +function restoreYjsVersion(fragment: Y.XmlFragment, content: Uint8Array) { + const doc = new Y.Doc(); + try { + Y.applyUpdate(doc, content); + const children = findTypeInOtherYdoc(fragment, doc) + .slice() + .map((child) => child.clone()); + fragment.doc!.transact(() => { + fragment.delete(0, fragment.length); + fragment.insert(0, children); + }); + } finally { + doc.destroy(); + } +} const DEFAULT_STORAGE_KEY = "blocknote-versioning-yjs-snapshots"; @@ -15,15 +31,16 @@ function getContentsKey(storageKey: string) { } function readSnapshots(storageKey: string): VersionSnapshot[] { - return sortSnapshotsNewestFirst( - JSON.parse(localStorage.getItem(storageKey) ?? "[]") as VersionSnapshot[], - ); + const snapshots = JSON.parse( + localStorage.getItem(storageKey) ?? "[]", + ) as VersionSnapshot[]; + return snapshots.sort((a, b) => b.createdAt - a.createdAt); } function writeSnapshots(storageKey: string, snapshots: VersionSnapshot[]) { localStorage.setItem( storageKey, - JSON.stringify(sortSnapshotsNewestFirst(snapshots)), + JSON.stringify([...snapshots].sort((a, b) => b.createdAt - a.createdAt)), ); } @@ -45,116 +62,90 @@ function writeContents(storageKey: string, contents: Record) { * v2 encoding used by the `@y/y` (v14) equivalent. */ export function createLocalStorageVersioningEndpoints( + fragment: Y.XmlFragment, storageKey = DEFAULT_STORAGE_KEY, -): VersioningEndpoints { - const listSnapshots: VersioningEndpoints< - Y.XmlFragment, - Uint8Array - >["list"] = async () => { - // Surface the live document as a "current version" entry at the top — it's - // how the user returns to live editing and compares against saved - // snapshots. It isn't a stored snapshot, so it's never passed to - // `getContent` (the sidebar previews it live via `previewCurrentVersion`). - const current: VersionSnapshot = { - id: CURRENT_VERSION_ID, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - return [current, ...readSnapshots(storageKey)]; +): VersionStorage { + const listSnapshots: VersionStorage["list"] = async () => { + // The current version is the live document. There's no server clock here, + // so it's simply stamped "now"; it isn't a stored snapshot, so it's never + // passed to `getContent` (the sidebar previews it live via + // `previewCurrentVersion`). + return { ok: true, value: { snapshots: readSnapshots(storageKey) } }; }; - // Stored snapshots always have string ids (only the synthetic current - // entry carries the CURRENT_VERSION_ID symbol, and it never reaches these - // endpoints), so coercing ids to strings below is safe. const createSnapshot: NonNullable< - VersioningEndpoints["create"] - > = async (fragment, options) => { + VersionStorage["create"] + > = async (content, name) => { const snapshot = { id: crypto.randomUUID(), - name: options?.name, + name, createdAt: Date.now(), - updatedAt: Date.now(), - restoredFromSnapshotId: options?.restoredFromSnapshot - ? String(options.restoredFromSnapshot.id) - : undefined, } satisfies VersionSnapshot; const contents = readContents(storageKey); - contents[snapshot.id] = toBase64(Y.encodeStateAsUpdate(fragment.doc!)); + contents[snapshot.id] = toBase64(content); writeContents(storageKey, contents); writeSnapshots(storageKey, [snapshot, ...readSnapshots(storageKey)]); - return snapshot; + return { ok: true, value: snapshot }; }; - const fetchSnapshotContent: VersioningEndpoints< - Y.XmlFragment, - Uint8Array - >["getContent"] = async (snapshot) => { - const id = String(snapshot.id); + const fetchSnapshotContent: VersionStorage["getContent"] = async ( + id, + signal, + ) => { + signal.throwIfAborted(); const encoded = readContents(storageKey)[id]; if (encoded === undefined) { - throw new Error(`Document snapshot ${id} could not be found.`); + return { ok: false, error: { type: "not-found" } }; } - return fromBase64(encoded); + return { ok: true, value: fromBase64(encoded) }; }; - const restoreSnapshot: VersioningEndpoints< - Y.XmlFragment, - Uint8Array - >["restore"] = async (fragment, snapshot) => { - await createSnapshot(fragment, { name: "Backup" }); - - const snapshotContent = await fetchSnapshotContent(snapshot); - const yDoc = new Y.Doc(); - Y.applyUpdate(yDoc, snapshotContent); - - await createSnapshot(yDoc.getXmlFragment("document-store"), { - name: "Restored Snapshot", - restoredFromSnapshot: snapshot, - }); - - return snapshotContent; + const restoreSnapshot: VersionStorage["restore"] = async (id) => { + const snapshotContent = await fetchSnapshotContent( + id, + new AbortController().signal, + ); + if (!snapshotContent.ok) return snapshotContent; + const backup = await createSnapshot( + Y.encodeStateAsUpdate(fragment.doc!), + "Backup", + ); + if (!backup.ok) return backup; + restoreYjsVersion(fragment, snapshotContent.value); + return { ok: true, value: undefined }; }; - const rename: VersioningEndpoints< - Y.XmlFragment, - Uint8Array - >["rename"] = async (snapshot, name) => { + const rename: VersionStorage["rename"] = async (id, name) => { const snapshots = readSnapshots(storageKey); - const stored = snapshots.find((s) => s.id === snapshot.id); + const stored = snapshots.find((s) => s.id === id); if (stored === undefined) { - throw new Error( - `Document snapshot ${String(snapshot.id)} could not be found.`, - ); + return { ok: false, error: { type: "not-found" } }; } stored.name = name; - stored.updatedAt = Date.now(); writeSnapshots(storageKey, snapshots); + return { ok: true, value: undefined }; }; - const remove: VersioningEndpoints< - Y.XmlFragment, - Uint8Array - >["remove"] = async (snapshot) => { + const remove: VersionStorage["remove"] = async (id) => { const snapshots = readSnapshots(storageKey); - if (!snapshots.some((s) => s.id === snapshot.id)) { - throw new Error( - `Document snapshot ${String(snapshot.id)} could not be found.`, - ); + if (!snapshots.some((s) => s.id === id)) { + return { ok: false, error: { type: "not-found" } }; } // Drop the snapshot metadata and its stored content. writeSnapshots( storageKey, - snapshots.filter((s) => s.id !== snapshot.id), + snapshots.filter((s) => s.id !== id), ); const contents = readContents(storageKey); - delete contents[String(snapshot.id)]; + delete contents[id]; writeContents(storageKey, contents); + return { ok: true, value: undefined }; }; return { @@ -168,4 +159,27 @@ export function createLocalStorageVersioningEndpoints( } /** Default localStorage-backed endpoints using {@link DEFAULT_STORAGE_KEY}. */ -export const localStorageEndpoints = createLocalStorageVersioningEndpoints(); + +/** Whether any versions have been stored under `storageKey` yet. */ +export function hasStoredVersions(storageKey = DEFAULT_STORAGE_KEY): boolean { + return localStorage.getItem(storageKey) !== null; +} + +/** + * Store versions directly, bypassing `create`: the demo seeds sample history + * with back-dated timestamps, which `create` (which stamps "now") can't do. + */ +export function storeVersions( + versions: Array<{ name?: string; createdAt: number; content: Uint8Array }>, + storageKey = DEFAULT_STORAGE_KEY, +) { + const snapshots = readSnapshots(storageKey); + const contents = readContents(storageKey); + for (const version of versions) { + const id = crypto.randomUUID(); + snapshots.push({ id, name: version.name, createdAt: version.createdAt }); + contents[id] = toBase64(version.content); + } + writeContents(storageKey, contents); + writeSnapshots(storageKey, snapshots); +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts b/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts new file mode 100644 index 0000000000..636f4546fb --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts @@ -0,0 +1,127 @@ +import { BlockNoteEditor, type PartialBlock } from "@blocknote/core"; +import { prosemirrorToYXmlFragment } from "y-prosemirror"; +import * as Y from "yjs"; + +export const DAY_MS = 24 * 60 * 60 * 1000; + +// Stable ids let previews show edits to the same blocks across versions. +type SampleBlock = PartialBlock & { + id: string; + type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem"; + content: string; +}; + +function updateContent(blocks: SampleBlock[], updates: Record) { + return blocks.map((block) => ({ + ...block, + content: updates[block.id] ?? block.content, + })); +} + +const firstDraft: SampleBlock[] = [ + { + id: "title", + type: "heading", + props: { level: 2 }, + content: "Launch plan: Notes 2.0", + }, + { + id: "goal", + type: "paragraph", + content: + "Goal: ship the new editor to every workspace before the end of the quarter.", + }, + { + id: "milestones", + type: "heading", + props: { level: 3 }, + content: "Milestones", + }, + { + id: "m1", + type: "bulletListItem", + content: "Beta with five design partners", + }, + { id: "m3", type: "bulletListItem", content: "Public release" }, +]; + +const addedDates = updateContent( + [ + ...firstDraft.slice(0, -1), + { + id: "m2", + type: "bulletListItem", + content: "Fix the ten most-reported beta issues", + }, + firstDraft[firstDraft.length - 1]!, + ], + { + goal: "Goal: ship the new editor to every workspace before the end of September.", + m1: "Beta with five design partners (June)", + m3: "Public release (September)", + }, +); + +const marketingReview: SampleBlock[] = [ + ...updateContent(addedDates, { m3: "Public release (September 15)" }), + { + id: "announcement", + type: "heading", + props: { level: 3 }, + content: "Announcement", + }, + { + id: "announcement-text", + type: "paragraph", + content: + "The blog post and changelog entry go out on release day. The newsletter follows a week later.", + }, +]; + +const liveDocument: SampleBlock[] = [ + ...updateContent(marketingReview, { + goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.", + }), + { + id: "questions", + type: "heading", + props: { level: 3 }, + content: "Open questions", + }, + { + id: "q1", + type: "numberedListItem", + content: "Do we keep the old editor available as a fallback?", + }, + { + id: "q2", + type: "numberedListItem", + content: "Who owns the migration guide?", + }, +]; + +export const SAMPLE_HISTORY: Array<{ + name: string; + daysAgo: number; + blocks: PartialBlock[]; +}> = [ + { name: "First draft", daysAgo: 9, blocks: firstDraft }, + { name: "Added dates", daysAgo: 6, blocks: addedDates }, + { name: "Marketing review", daysAgo: 2, blocks: marketingReview }, +]; + +export const LIVE_DOCUMENT: PartialBlock[] = liveDocument; + +/** Encode sample blocks as a stored Yjs version. */ +export function blocksToUpdate( + blocks: PartialBlock[], + fragmentName: string, +): Uint8Array { + const editor = BlockNoteEditor.create({ initialContent: blocks }); + const doc = new Y.Doc(); + prosemirrorToYXmlFragment( + editor.prosemirrorState.doc, + doc.getXmlFragment(fragmentName), + ); + return Y.encodeStateAsUpdate(doc); +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/style.css b/examples/07-collaboration/11-versioning-yjs13/src/style.css index e75d6ef7b8..011c59707d 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/style.css +++ b/examples/07-collaboration/11-versioning-yjs13/src/style.css @@ -2,54 +2,63 @@ height: calc(100vh - 20px); } -.wrapper > .bn-container { - margin: 0; - max-width: none; - padding: 0; -} - .layout { display: flex; gap: 8px; height: calc(100vh - 20px); } -.editor-panel { +.layout > .bn-container { flex: 1; - height: calc(100vh - 20px); - min-width: 0; - overflow: auto; -} - -.editor-panel .bn-container { height: calc(100vh - 20px); margin: 0; max-width: none; + min-width: 0; + overflow: auto; padding: 0; + position: relative; } -.editor-panel .bn-editor { +.layout > .bn-container .bn-editor { height: calc(100vh - 20px); overflow: auto; } +/* The history panel is a slot in the app's layout; the sidebar is rendered + into it through `RenderInPortalElement`. */ .sidebar-section { - background-color: var(--bn-colors-disabled-background); display: flex; flex-direction: column; height: calc(100vh - 20px); - overflow: auto; width: 350px; } +/* The themed root `RenderInPortalElement` creates in the slot. Only inside it + do the `--bn-*` variables follow the editor's (light or dark) theme. */ +.sidebar-section > .bn-root { + background-color: var(--bn-colors-disabled-background); + display: flex; + flex: 1; + min-height: 0; +} + .sidebar-section .settings { padding: 8px; } -.bn-versioning-sidebar { - flex: 1; - overflow: auto; - padding-inline: 16px; +.show-history-button { + 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; + font-size: 13px; + font-weight: 600; + padding: 6px 12px; + position: absolute; + right: 16px; + top: 16px; } .settings-select { @@ -69,73 +78,12 @@ padding-left: 14px; } -.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; - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 10px; - overflow: visible; - padding: 16px 32px; - width: 100%; -} - -.bn-snapshot-name { - background: transparent; - border: none; - color: var(--bn-colors-menu-text); - font-size: 16px; - font-weight: 600; - padding: 0; - width: 100%; -} - -.bn-snapshot-name:focus { - outline: none; -} - -.bn-snapshot-body { - display: flex; - flex-direction: column; - font-size: 12px; - gap: 4px; -} - -.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; -} - -.dark .bn-snapshot-button { - background-color: #0070e8; -} - -.bn-snapshot-button:hover { - background-color: #73b7ff; -} - -.dark .bn-snapshot-button:hover { - background-color: #3785d8; -} - -.bn-versioning-sidebar .bn-snapshot.selected { - background-color: #f5f9fd; - border: 2px solid #c2dcf8; -} +@media (max-width: 600px) { + .layout { + gap: 0; + } -.dark .bn-versioning-sidebar .bn-snapshot.selected { - background-color: #20242a; - border: 2px solid #23405b; + .sidebar-section { + width: 100%; + } } diff --git a/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json index 1ff8718e2c..6ed3995246 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json +++ b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json @@ -6,7 +6,8 @@ "dependencies": { "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", - "lib0": "^1.0.0-rc.34" + "@y/y": "^14.0.0-rc.26", + "lib0": "1.0.0-rc.36", + "@y/prosemirror": "^2.0.0-14" } } diff --git a/examples/07-collaboration/12-multi-doc-versioning/README.md b/examples/07-collaboration/12-multi-doc-versioning/README.md index af4adf48e0..862da9c4d6 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/README.md +++ b/examples/07-collaboration/12-multi-doc-versioning/README.md @@ -1,6 +1,8 @@ # YHub Multi-Doc -This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `VersioningExtension` and Y.js v14. +This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `YVersioningExtension` and Y.js v14. Sync and history both come from [YHub](https://github.com/yjs/yhub), which records every edit and groups them into versions. + +A first visit creates a sample document whose history already has several versions by several users, so the history sidebar has something to show right away. The editor is read-only while the sidebar is open: close it to edit, then reopen it with the "History" button. **Features:** @@ -8,7 +10,7 @@ This example shows a multi-document collaborative editor with per-document versi - Left sidebar with document list (create, rename, delete) - Collaborative editing with Y.js (including suggestion mode) - Right sidebar with version history powered by `VersioningSidebar` -- Per-document versioning backed by `localStorage` +- Per-document version history backed by YHub - Open multiple tabs with different users via the `?as=` URL param **Relevant Docs:** diff --git a/examples/07-collaboration/12-multi-doc-versioning/package.json b/examples/07-collaboration/12-multi-doc-versioning/package.json index 8c3b7b037b..44a118a868 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/package.json +++ b/examples/07-collaboration/12-multi-doc-versioning/package.json @@ -22,8 +22,9 @@ "react-dom": "^19.2.3", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", - "lib0": "^1.0.0-rc.34" + "@y/y": "^14.0.0-rc.26", + "lib0": "1.0.0-rc.36", + "@y/prosemirror": "^2.0.0-14" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx index 2d5d2e5d25..8faffcff23 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx +++ b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx @@ -9,6 +9,18 @@ import { generateRandomId } from "./utils.js"; import { LoginScreen } from "./LoginScreen.js"; import { DocumentList } from "./DocumentList.js"; import { DocumentEditor } from "./DocumentEditor.js"; +import { + SAMPLE_DOCUMENT_TITLE, + seedSampleDocument, + hasPendingSampleDocument, +} from "./sampleDocument.js"; +import { YHUB_API_URL } from "./yhub.js"; + +// Set once the sample document has been created, so deleting every document +// leaves the workspace empty rather than bringing the sample back. +function seededKey(workspaceId: string) { + return `bn-multi-doc-seeded:${workspaceId}`; +} export default function App() { const user = useCurrentUser(); @@ -38,7 +50,14 @@ export default function App() { const workspaceId = seg1; const docId = seg2 || null; - return ; + return ( + + ); } function Workspace({ @@ -50,10 +69,48 @@ function Workspace({ workspaceId: string; docId: string | null; }) { - const index = useDocIndex(); + const index = useDocIndex(workspaceId, docId); const activeDoc = docId ? index.docs.find((d) => d.id === docId) : null; const [copied, setCopied] = useState(false); + // A first visit gets a sample document with a few versions in its history, + // so the history sidebar has something to show before anyone has edited. + const [seedStatus, setSeedStatus] = useState<"idle" | "seeding" | "failed">( + "idle", + ); + const [seedAttempt, setSeedAttempt] = useState(0); + const seedStartedRef = useRef(false); + useEffect(() => { + if ( + docId || + (index.docs.length > 0 && + !hasPendingSampleDocument({ + baseUrl: YHUB_API_URL, + org: workspaceId, + })) || + localStorage.getItem(seededKey(workspaceId)) || + seedStartedRef.current + ) { + return; + } + seedStartedRef.current = true; + setSeedStatus("seeding"); + void seedSampleDocument({ + baseUrl: YHUB_API_URL, + org: workspaceId, + }) + .then((id) => { + index.ensure(id, SAMPLE_DOCUMENT_TITLE); + localStorage.setItem(seededKey(workspaceId), "1"); + setSeedStatus("idle"); + navigate(`/w/${workspaceId}/${id}`); + }) + .catch((error: unknown) => { + console.error("Could not seed the sample document", error); + setSeedStatus("failed"); + }); + }, [docId, index, workspaceId, seedAttempt]); + // A shared doc URL can reference a doc this browser has never seen (the // index is localStorage-only). Register it so the editor mounts and syncs // the content from the server. Ensure each id at most once per mount so @@ -90,7 +147,8 @@ function Workspace({ const signOut = () => { setCurrentUser(null); - navigate("/"); + // Keep the workspace/document route as the login redirect, including + // across reloads. Signing out changes identity, not document ownership. }; const switchUser = (id: string) => { @@ -134,7 +192,22 @@ function Workspace({ workspaceId={workspaceId} activeDocId={docId} /> - {activeDoc ? ( + {seedStatus === "seeding" ? ( +
Preparing a sample document…
+ ) : seedStatus === "failed" ? ( +
+

Could not prepare the sample document.

+ +
+ ) : activeDoc ? ( ; - versioningEndpoints: ReturnType; } | null>(null); if (!resourcesRef.current) { @@ -64,20 +78,14 @@ export function DocumentEditor({ } const suggestionDoc = new Y.Doc({ isSuggestionDoc: true }); - const yhubHost = "yhub.teleportal.tools"; - const provider = new WebsocketProvider( - `wss://${yhubHost}/ws`, - roomName, - doc, - { - params: { - userid: user.id, - }, + const provider = new WebsocketProvider(YHUB_WS_URL, roomName, doc, { + params: { + userid: user.id, }, - ); + }); const suggestionProvider = new WebsocketProvider( - `wss://${yhubHost}/ws`, + YHUB_WS_URL, roomName + "-suggestions", suggestionDoc, { @@ -88,30 +96,17 @@ export function DocumentEditor({ ); const renderer = Y.createDiffRenderer(doc, suggestionDoc); - const versioningEndpoints = createYHubVersioningEndpoints({ - baseUrl: `https://${yhubHost}`, - org: workspaceId, - docId, - }); - resourcesRef.current = { doc, suggestionDoc, provider, suggestionProvider, renderer, - versioningEndpoints, }; } - const { - doc, - suggestionDoc, - provider, - suggestionProvider, - renderer, - versioningEndpoints, - } = resourcesRef.current; + const { doc, suggestionDoc, provider, suggestionProvider, renderer } = + resourcesRef.current; // Clean up on unmount useEffect(() => { @@ -165,6 +160,24 @@ export function DocumentEditor({ }; }, [provider]); + // Wait for initial sync before capturing Current, so history does not open + // on an empty local document while the provider is still connecting. + const [synced, setSynced] = useState(provider.synced); + useEffect(() => { + const onSync = (isSynced: boolean) => { + if (isSynced) { + setSynced(true); + } + }; + provider.on("sync", onSync); + if (provider.synced) { + setSynced(true); + } + return () => { + provider.off("sync", onSync); + }; + }, [provider]); + const editor = useCreateBlockNote( withCollaboration({ collaboration: { @@ -177,31 +190,27 @@ export function DocumentEditor({ name: user.username, id: user.id, }, - versioningEndpoints, // Resolves version-author ids (YHub's `by`) to usernames in the history // sidebar and diff tooltips. resolveUsers, }, + extensions: [ + YHubVersioningExtension({ + baseUrl: YHUB_API_URL, + org: workspaceId, + docId, + queryParams: { userid: user.id }, + }), + ], }), ); - // The version history is derived entirely from YHub's activity timeline. - // Fetch it once on mount so the sidebar reflects the server's history rather - // than only changes made during this session. - const versioning = useExtension(VersioningExtension, { editor }); - useEffect(() => { - versioning.list(); - const interval = setInterval(() => { - versioning.list(); - }, 10000); - return () => { - clearInterval(interval); - }; - }, [versioning]); - - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { - editor, - }); + // The version history is derived entirely from YHub's activity timeline; the + // sidebar fetches it once when it opens. + const versioningView = useStore( + editor.getExtension("versioning")!.store, + ); + const previewing = versioningView.mode !== "live"; const { enableSuggestions, disableSuggestions, viewSuggestions } = useExtension(SuggestionsExtension, { editor }); @@ -212,11 +221,11 @@ export function DocumentEditor({ // Exit suggestion modes when entering version preview useEffect(() => { - if (previewedSnapshotId !== undefined && editingMode !== "editing") { + if (previewing && editingMode !== "editing") { disableSuggestions(); setEditingMode("editing"); } - }, [previewedSnapshotId]); + }, [previewing]); const modeOptions = useMemo( () => [ @@ -244,11 +253,9 @@ export function DocumentEditor({ }; return ( - + // No `editable` prop: the versioning sidebar owns editability while it's + // open, and restores it on close. +

{docTitle || "Untitled"}

- {previewedSnapshotId === undefined && ( + {!previewing && ( - applyGroupMaxGap(Number(e.currentTarget.value)) - } - > - {GROUP_GAP_STOPS.map((ms) => ( - - ))} - -
- -
- - -
- -
- -
-
- )} - - -
- {showSidebar && ( -
- setShowSidebar(false)} /> -
- )} -
+
+ {/* The sidebar makes the editor read-only for as long as it is open — + that's the versioning extension's job, so there's no `editable` prop + to manage here. */} + + {!showSidebar && ( + + )} + {showSidebar && sidebarPanel && ( + + setShowSidebar(false)} /> + + )} + {showSidebar &&
}
); } diff --git a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts index 78a637204e..9a0a01c9c7 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts @@ -3,6 +3,7 @@ import { BlockNoteEditor } from "@blocknote/core"; import { buildEditHistory } from "./snapshotBuilder"; import type { EditHistoryStep } from "./snapshotBuilder"; import { seedYHubDocument } from "./seed"; +import type { SeededVersion } from "./seed"; import { VERSIONS } from "./versions"; /** @@ -26,8 +27,8 @@ import { VERSIONS } from "./versions"; * * Each emitted op becomes its own captured transaction, attributed to one of * the version's authors at random, and `seedYHubDocument` lands them as - * separate authored content before committing a single version marker — so the - * one version is attributed to several authors. + * separate authored content — so grouping merges them back into one version + * attributed to several authors. */ /** Each version's target tree plus the 2–3 users who collaborate on it. */ @@ -61,19 +62,21 @@ const VERSION_PLAN: EditHistoryStep[] = [ /** * Build the sample document's history offline and seed it to YHub under the * given coordinates, so the live editor syncs the content and the version - * sidebar shows one snapshot per step. + * sidebar shows one version per step. * * The `fragment` must match the key the live editor reads (`doc.get(fragment)`). + * + * @returns versions named on YHub at their last edit's timestamp. */ export async function seedSampleVersions(opts: { baseUrl: string; org: string; docId: string; fragment: string; -}): Promise { +}): Promise { const editor = BlockNoteEditor.create(); const build = await buildEditHistory(editor, VERSION_PLAN, { fragment: opts.fragment, }); - await seedYHubDocument(opts, build); + return seedYHubDocument(opts, build); } diff --git a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts index 52e1779274..286778d4c8 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts @@ -18,9 +18,13 @@ export interface SeedYHubDocumentOptions { headers?: Record; } -/** A version marker created on the server while seeding. */ +/** A named version produced by seeding. */ export interface SeededVersion { - id: string; + /** + * The version's server timestamp — the `to` of its last seeded edit, and so + * the timestamp of its native YHub named version. + */ + to: number; name: string; } @@ -42,45 +46,29 @@ type YHubPatch = { by?: string; /** Timestamp override (unix ms), so backfilled history stays ordered. */ at?: number; - /** Custom attributions riding this patch's content (e.g. version markers). */ + /** Custom attributions riding this patch's content. */ customAttributions?: Array<{ k: string; v: string }>; }; -/** Build the throwaway novel content a version marker rides on (see yhub.ts `patchDoc`). */ -function makeVersionMarkerUpdate(): Uint8Array { - // YHub only records custom attributions when they attach to NEW content that - // survives its server-side diff. The version's real content was already - // PATCHed (attributed to individual users), so the marker needs its own scrap - // of novel content: a single insert into a dedicated `__bn_version_markers` - // fragment the editor never renders. A fresh Y.Doc guarantees a clientID the - // server has never seen, so the diff is non-empty and the marker lands. - const markerDoc = new Y.Doc(); - markerDoc.get("__bn_version_markers", "XmlFragment").insert(0, ["v"]); - return Y.encodeStateAsUpdate(markerDoc); -} - /** * Pre-populate a YHub document with content **and** version history from a * {@link buildEditHistory} result, without a live editor / sync connection. * - * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` as a - * single ordered `patches` bulk request: one content patch per captured - * transaction (attributed via `by`, **no** version marker), followed by one - * marker patch carrying a `type:version` custom attribution — the same marker - * {@link createYHubVersioningEndpoints}'s `create` uses. Because the version's - * attribution window spans all of its content patches, **multiple users are - * attributed within the one version**. The starting document state - * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first, without a - * marker, so the step patches have their baseline to merge onto. + * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` + * as a single ordered `patches` bulk request: one content patch per captured + * transaction, attributed via `by`. Each step is named through YHub's native + * version API at its last edit's timestamp. The starting document state + * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first so the step + * patches have their baseline to merge onto. * * Every patch carries the explicit `at` timestamp captured by * {@link buildEditHistory}, so the backfilled history stays deterministically - * ordered (content before its marker, each version after the previous one). + * ordered (each version after the previous one). * * YHub speaks the V1 update format, so the V2 updates `buildEditHistory` - * produces are converted; the synthetic marker update is already V1. + * produces are converted. * - * @returns the version markers created, in order. + * @returns each named version and its last edit's timestamp, in order. * * @example * ```ts @@ -99,6 +87,7 @@ export async function seedYHubDocument( ): Promise { const { baseUrl, org, docId, headers = {} } = options; const url = `${baseUrl}/ydoc/v1/${org}/${docId}`; + const versionUrl = `${baseUrl}/version/v1/${org}/${docId}`; const send = async (body: Record) => { const res = await fetch(url, { @@ -113,17 +102,18 @@ export async function seedYHubDocument( } }; - // 1. Starting document state — content only, no version marker. Timestamp it - // just before the first captured transaction so it sorts first. + // 1. Starting document state. Timestamp it just before the first captured + // transaction so it sorts first. await send({ update: Y.convertUpdateFormatV2ToV1(build.baseUpdate), - at: build.steps[0]?.patches[0]?.at ?? Date.now(), + at: (build.steps[0]?.patches[0]?.at ?? Date.now()) - 1, customAttributions: [], }); - // 2. Each step: one content patch per captured transaction, then a single - // `type:version` marker patch so it appears as one snapshot attributed to - // every author. + // 2. Each step: one content patch per captured transaction. The step's last + // edit ends its group (the next version is days away, well past the + // example's `groupMaxGap`), so `step.at` is the timestamp the version's + // name attaches to. const versions: SeededVersion[] = []; for (const step of build.steps) { const patches: YHubPatch[] = step.patches.map((p) => ({ @@ -132,22 +122,23 @@ export async function seedYHubDocument( at: p.at, customAttributions: [], })); - // The marker patch carries the version itself. YHub attributes an entry to a - // single user, so credit the version to its last contributor (the per-content - // attribution still records who authored each part). - patches.push({ - update: makeVersionMarkerUpdate(), - by: step.by, - at: step.at, - customAttributions: [ - { k: "type", v: "version" }, - { k: "id", v: step.id }, - { k: "name", v: step.name }, - ], - }); await send({ patches }); - versions.push({ id: step.id, name: step.name }); + const res = await fetch(versionUrl, { + method: "POST", + headers, + body: encodeAny({ + type: "version:v1", + t: step.at, + name: step.name, + }) as BufferSource, + }); + if (!res.ok) { + throw new Error( + `YHub version request failed: ${res.status} ${res.statusText} (${versionUrl})`, + ); + } + versions.push({ to: step.at, name: step.name }); } return versions; diff --git a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts index de104ba037..db4bb3bf4e 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts @@ -2,7 +2,6 @@ import { BlockNoteEditor } from "@blocknote/core"; import { docDiffToDelta } from "@blocknote/core/y"; import { docToDelta } from "@y/prosemirror"; import * as Y from "@y/y"; -import { uint32 } from "lib0/random"; import { applyVersionUnbatched, type VersionBlock } from "./reconcile"; @@ -13,10 +12,11 @@ import { applyVersionUnbatched, type VersionBlock } from "./reconcile"; * reconciles the *same* editor instance towards a target document, producing a * burst of ProseMirror transactions. We capture every content-changing * transaction, diff its before/after ProseMirror docs (`docDiffToDelta`), apply - * that delta to a plain Y.Type in its own Yjs transaction (tagged with a random + * that delta to a plain Y.Node in its own Yjs transaction (tagged with a random * author as origin), and record the resulting V2 update. The captured updates - * can later be PATCHed to a server (see seed.ts) to rebuild the history, with a - * `type:version` marker committed at the end of each step. + * can later be PATCHed to a server (see seed.ts) to rebuild the history: each + * step's edits are separated from the next step's by a large gap, which is what + * makes them read as one version. * * The backing Y.Doc has gc disabled so history stays reconstructable. */ @@ -50,7 +50,6 @@ export type BuildEditHistoryResult = { /** One entry per step, in order, each carrying its captured transactions. */ steps: Array<{ name: string; - id: string; by?: string; at: number; patches: CapturedPatch[]; @@ -139,8 +138,8 @@ export async function buildEditHistory( const ydoc = new Y.Doc({ gc: false }); const yType = ydoc.get(options.fragment); - // Seed the Y.Type with the editor's starting doc so that every subsequent - // diff is relative to a Y.Type that actually mirrors the editor. Capture the + // Seed the Y.Node with the editor's starting doc so that every subsequent + // diff is relative to a Y.Node that actually mirrors the editor. Capture the // empty state vector first so we can expose the seed as `baseUpdate`. const emptyStateVector = Y.encodeStateVector(ydoc); ydoc.transact(() => { @@ -215,9 +214,8 @@ export async function buildEditHistory( }); resultSteps.push({ name: step.name, - id: String(uint32()), by: lastAuthor, - // Marker right after this version's last edit. + // This version's last edit, which is what its name attaches to. at: Math.floor(clock), patches, }); diff --git a/examples/07-collaboration/13-versioning-yjs14/src/style.css b/examples/07-collaboration/13-versioning-yjs14/src/style.css index 7edddb292a..308bb2f171 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/style.css +++ b/examples/07-collaboration/13-versioning-yjs14/src/style.css @@ -14,161 +14,52 @@ font-family: system-ui, sans-serif; } -.wrapper > .bn-container { - margin: 0; - max-width: none; - padding: 0; -} - .layout { display: flex; gap: 0; height: calc(100vh - 20px); } -.editor-panel { +.layout > .bn-container { flex: 1; - height: calc(100vh - 20px); - min-width: 0; - overflow: auto; - position: relative; -} - -.editor-panel .bn-container { height: calc(100vh - 20px); margin: 0; max-width: none; + min-width: 0; + overflow: auto; padding: 0; + position: relative; } -.editor-panel .bn-editor { +.layout > .bn-container .bn-editor { height: calc(100vh - 20px); overflow: auto; } -/* The history panel sits flush against the editor with a subtle divider. */ +/* The history panel is a slot in the app's layout; the sidebar is rendered + into it through `RenderInPortalElement`. */ .sidebar-section { - background-color: var(--bn-colors-editor-background); - border-left: 1px solid var(--bn-colors-border); box-shadow: -6px 0 16px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; height: calc(100vh - 20px); - overflow: auto; width: 350px; } -.dark .sidebar-section { - border-left-color: #2c2c2c; - box-shadow: -6px 0 16px rgba(0, 0, 0, 0.3); +/* The themed root `RenderInPortalElement` creates in the slot. Only inside it + do the `--bn-*` variables follow the editor's (light or dark) theme. */ +.sidebar-section > .bn-root { + background-color: var(--bn-colors-editor-background); + border-left: 1px solid var(--bn-colors-border); + display: flex; + flex: 1; + min-height: 0; } .sidebar-section .settings { padding: 8px; } -/* Floating gear button pinned to the bottom-left of the viewport, opening the - live grouping "Configuration" panel. Styled like `.show-history-button`. - Both the gear and the panel are `position: fixed` with a very high z-index so - they stay above BlockNote's floating UI (toolbars, menus), which is portaled - to `document.body` and would otherwise pop up over an in-flow panel. */ -.config-gear-button { - align-items: center; - background-color: var(--bn-colors-menu-background); - border: var(--bn-border); - border-radius: 50%; - box-shadow: var(--bn-shadow-medium); - bottom: 16px; - color: var(--bn-colors-menu-text); - cursor: pointer; - display: flex; - font-size: 18px; - height: 40px; - justify-content: center; - left: 16px; - line-height: 1; - position: fixed; - width: 40px; - z-index: 99999; -} - -.config-panel { - background-color: var(--bn-colors-menu-background); - border: var(--bn-border); - border-radius: var(--bn-border-radius-medium); - bottom: 68px; - box-shadow: var(--bn-shadow-medium); - color: var(--bn-colors-menu-text); - display: flex; - flex-direction: column; - gap: 14px; - left: 16px; - padding: 14px; - position: fixed; - width: 260px; - z-index: 99999; -} - -.config-panel-title { - font-size: 13px; - font-weight: 600; -} - -.config-row { - display: flex; - flex-direction: column; - gap: 6px; -} - -.config-row > label { - align-items: baseline; - display: flex; - font-size: 12px; - font-weight: 500; - justify-content: space-between; -} - -.config-row .config-value { - color: var(--bn-colors-menu-text); - font-weight: 400; - opacity: 0.7; -} - -.config-row input[type="range"] { - width: 100%; -} - -.config-row input[type="number"], -.config-select { - background: var(--bn-colors-editor-background); - border: 1px solid var(--bn-colors-border); - border-radius: var(--bn-border-radius-small); - color: var(--bn-colors-menu-text); - font-size: 12px; - padding: 4px 6px; - width: 100%; -} - -.config-unlimited { - align-items: center; - display: flex; - font-size: 12px; - gap: 6px; -} - -/* The mergeUsers boolean row: checkbox + label + a muted hint on the right. */ -.config-toggle { - align-items: center; - display: flex; - font-size: 12px; - font-weight: 500; - gap: 6px; -} - -.config-toggle .config-value { - margin-left: auto; -} - .show-history-button { background-color: var(--bn-colors-menu-background); border: var(--bn-border); @@ -200,36 +91,3 @@ line-height: 12px; padding-left: 14px; } - -/* The versioning sidebar's tab switcher (Named Versions / Version History). - The theme stylesheets (@blocknote/mantine etc.) also style these, but this - example imports the editor without a single theme stylesheet in scope for - the sidebar, so the tab rules are repeated here to guarantee they're styled. */ -.bn-versioning-sidebar-tabs { - border-bottom: 1px solid var(--bn-colors-border); - display: flex; - gap: 4px; - margin-bottom: 8px; -} - -.bn-versioning-sidebar-tab { - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--bn-colors-menu-text); - cursor: pointer; - font-size: 13px; - font-weight: 500; - margin-bottom: -1px; - opacity: 0.6; - padding: 8px 4px; -} - -.bn-versioning-sidebar-tab:hover { - opacity: 0.85; -} - -.bn-versioning-sidebar-tab[aria-selected="true"] { - border-bottom-color: var(--bn-colors-menu-text); - opacity: 1; -} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts index e692a99add..dbf2a04141 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts @@ -4,12 +4,15 @@ import type { User, UserStore } from "@blocknote/core"; // version sidebar / diff tooltips would show a bare number (e.g. "1") instead // of a name. The seed (`sampleDocument.ts`) attributes each contribution to one // of these ids via `attribution.by`. +// Colors are the `dark` values of BlockNote's own attribution palette +// (`userColorPalette`). Only `color` is set, so the pale mark background is +// derived from it (see `userMarkColors`). export const USERS: User[] = [ - { id: "1", username: "Alice", avatarUrl: "", color: "#e6194b" }, - { id: "2", username: "Bob", avatarUrl: "", color: "#3cb44b" }, - { id: "3", username: "Carol", avatarUrl: "", color: "#f58231" }, - { id: "4", username: "Dave", avatarUrl: "", color: "#4363d8" }, - { id: "5", username: "Erin", avatarUrl: "", color: "#911eb4" }, + { id: "1", username: "Alice", avatarUrl: "", color: "#3b3f9c" }, + { id: "2", username: "Bob", avatarUrl: "", color: "#0f6e62" }, + { id: "3", username: "Carol", avatarUrl: "", color: "#1e4fb0" }, + { id: "4", username: "Dave", avatarUrl: "", color: "#6b2fa3" }, + { id: "5", username: "Erin", avatarUrl: "", color: "#46525f" }, ]; /** diff --git a/examples/07-collaboration/14-suggestion-gallery/.bnexample.json b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json index e03899c16d..f4d27752dd 100644 --- a/examples/07-collaboration/14-suggestion-gallery/.bnexample.json +++ b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json @@ -4,8 +4,9 @@ "author": "yousefed", "tags": ["Advanced", "Development", "Collaboration"], "dependencies": { + "@blocknote/diagram-block": "latest", "@blocknote/xl-multi-column": "latest", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23" + "@y/y": "^14.0.0-rc.26" } } diff --git a/examples/07-collaboration/14-suggestion-gallery/package.json b/examples/07-collaboration/14-suggestion-gallery/package.json index 34aeb8f0b1..27931c7d4e 100644 --- a/examples/07-collaboration/14-suggestion-gallery/package.json +++ b/examples/07-collaboration/14-suggestion-gallery/package.json @@ -20,9 +20,10 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "@blocknote/diagram-block": "latest", "@blocknote/xl-multi-column": "latest", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23" + "@y/y": "^14.0.0-rc.26" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx index 15a1111dac..7373487f19 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -4,7 +4,7 @@ import "./style.css"; import type { GalleryEditor } from "./gallerySchema"; import { - createYjsVersioningAdapter, + createYVersionView, SuggestionsExtension, withCollaboration, } from "@blocknote/core/y"; @@ -19,7 +19,7 @@ import { gallerySchema } from "./gallerySchema"; import { buildSuggestionScenarioDocs, cloneDoc, - createAttributionStore, + createVersionMerge, docFromBlocks, } from "./scenarioDocs"; import { scenarios, SuggestionScenario } from "./scenarios"; @@ -35,8 +35,8 @@ function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness { // Hardcoded to match the attribution-mark palette (the colors BlockNote derives // per author id "A" / "B"), so a user's pane chrome matches their color in the // Diff / Merged panes. -const USER_A = { name: "User A", color: "#8a6d1a" }; -const USER_B = { name: "User B", color: "#8a2e24" }; +const USER_A = { name: "User A", color: "#46525f" }; +const USER_B = { name: "User B", color: "#8a6d1a" }; type Renderer = ReturnType; @@ -113,14 +113,8 @@ function SuggestionsView({ scenario }: { scenario: SuggestionScenario }) { }, []); const authors = suggestionAuthors(scenario); - const paneCount = 1 + authors.length + (authors.length > 1 ? 1 : 0); return ( -
= 4 ? " bn-gallery-editors--four" : "") - } - > +
Base (editable)
@@ -236,9 +230,7 @@ function UserSuggestion({ className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > -
- {label} -
+
{label}
); @@ -373,14 +365,7 @@ function VersioningView({ scenario }: { scenario: SuggestionScenario }) { }, []); return ( -
1 - ? "bn-gallery-editors--four" - : "bn-gallery-editors--three") - } - > +
Version 1 (editable)
@@ -411,19 +396,15 @@ function VersionMerge({ applyInitial: boolean; }) { const [setup] = useState(() => { - const afterDoc = cloneDoc(beforeDoc); - const ids = new Set(users.map((u) => u.id)); - // Record which user authored each merged change (by the Yjs origin the - // edits are forwarded with), so the Diff can color A's and B's - // contributions in their own colors instead of one flat diff color. - const attrs = createAttributionStore(afterDoc, (tr) => - ids.has(String(tr.origin)) ? String(tr.origin) : null, - ); + // Records which user authored each merged change, so the Diff can color + // A's and B's contributions in their own colors. + const merge = createVersionMerge(beforeDoc); return { userDocs: users.map(() => cloneDoc(beforeDoc)), - afterDoc, - attrs, - diffAwareness: new Awareness(afterDoc), + merge, + afterDoc: merge.doc, + attrs: merge.attributions, + diffAwareness: new Awareness(merge.doc), }; }); @@ -441,36 +422,37 @@ function VersionMerge({ useEffect(() => { // Forward every user edit into the merge doc (idempotent CRDT apply), so any // change to any user re-diffs. - // Forward with the author's id as the Yjs origin so the attribution store - // tags each merged change with its author. const offs = setup.userDocs.map((doc, i) => { - const origin = users[i].id; const onUpdate = (update: Uint8Array) => - Y.applyUpdate(setup.afterDoc, update, origin); + setup.merge.apply(update, users[i].id); doc.on("update", onUpdate); return () => doc.off("update", onUpdate); }); // Also pull in any edits that already flushed (the initial applies). setup.userDocs.forEach((doc, i) => - Y.applyUpdate(setup.afterDoc, Y.encodeStateAsUpdate(doc), users[i].id), + setup.merge.apply(Y.encodeStateAsUpdate(doc), users[i].id), ); - const adapter = createYjsVersioningAdapter( + const view = createYVersionView( diffEditor, setup.afterDoc.get("doc"), - ); + ).open(); const renderDiff = () => - adapter.preview.enterPreview( - Y.encodeStateAsUpdateV2(setup.afterDoc), - Y.encodeStateAsUpdateV2(beforeDoc), - setup.attrs, - ); + view.show({ + content: Y.encodeStateAsUpdateV2(setup.afterDoc), + comparison: { + content: Y.encodeStateAsUpdateV2(beforeDoc), + attributions: setup.attrs, + }, + target: { type: "current" }, + }); renderDiff(); setup.afterDoc.on("update", renderDiff); return () => { offs.forEach((off) => off()); setup.afterDoc.off("update", renderDiff); + view.close(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -535,9 +517,7 @@ function UserVersion({ className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > -
- {label} -
+
{label}
); diff --git a/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts b/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts index 80160e5d41..0e2a38d882 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts @@ -4,17 +4,22 @@ import { PartialBlock, withPageBreak, } from "@blocknote/core"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; import { withMultiColumn } from "@blocknote/xl-multi-column"; /** - * The gallery's editor schema: the default blocks plus `pageBreak` and - * multi-column (`columnList` / `column`) so scenarios can exercise those block - * types and load the shared `testDocument`. It's a superset of the default - * schema, so every existing scenario keeps working. The gallery editors AND the + * The gallery's editor schema: the default blocks plus `pageBreak`, + * multi-column (`columnList` / `column`) and a Mermaid `diagram` so scenarios + * can exercise those block types and load the shared `testDocument`. It's a + * superset of the default schema, so every existing scenario keeps working. The gallery editors AND the * shared test fixtures both build on this so the two never drift. */ export const gallerySchema = withMultiColumn( - withPageBreak(BlockNoteSchema.create()), + withPageBreak( + BlockNoteSchema.create().extend({ + blockSpecs: { diagram: createReactDiagramBlockSpec() }, + }), + ), ); type GallerySchema = typeof gallerySchema; diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts index 551db51ed1..8d6754ffc1 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts @@ -64,7 +64,7 @@ export function buildSuggestionScenarioDocs( const suggestionDoc = cloneDoc(baseDoc, { isSuggestionDoc: true }); suggestionDoc.clientID = i + 2; const manager = Y.createDiffRenderer(baseDoc, suggestionDoc, { - attrs: createAttributionStore(suggestionDoc, (tr) => + attributions: createAttributionStore(suggestionDoc, (tr) => tr.local ? id : null, ), }); @@ -78,7 +78,7 @@ export function buildSuggestionScenarioDocs( const doc = cloneDoc(baseDoc, { isSuggestionDoc: true }); doc.clientID = authorIds.length + 2; const manager = Y.createDiffRenderer(baseDoc, doc, { - attrs: createAttributionStore(doc, (tr) => + attributions: createAttributionStore(doc, (tr) => authorIds.includes(String(tr.origin)) ? String(tr.origin) : null, ), }); @@ -92,7 +92,7 @@ export function buildSuggestionScenarioDocs( /** * In-memory attribution store — records the author of each transaction into a - * mutable `Y.Attributions` so suggestion marks render in their author's color. + * mutable `Y.ContentMap` so suggestion marks render in their author's color. * `resolveUserId` returns the author id, or null to leave a change unattributed * (the base seed and the manager's own base→suggestion flow carry no author). * Mirrors the store in `concurrentSuggestionFixture.tsx`. @@ -100,8 +100,8 @@ export function buildSuggestionScenarioDocs( export function createAttributionStore( doc: Y.Doc, resolveUserId: (tr: any) => string | null, -): Y.Attributions { - const attrs = new Y.Attributions(); +): Y.ContentMap { + const attrs = Y.createContentMap(); doc.on("beforeObserverCalls", (tr: any) => { const userId = resolveUserId(tr); if (userId == null) { @@ -127,5 +127,45 @@ export function createAttributionStore( return attrs; } +/** + * The merge that Versioning mode diffs against Version 1: each user's updates + * applied onto a copy of `beforeDoc`, attributed the way a server like YHub + * does. A user is credited with what their update inserts and with what it + * deletes itself, not with content Yjs removes because another user deleted + * its parent. Deleted content is kept, as stored history keeps it. + */ +export function createVersionMerge(beforeDoc: Y.Doc) { + const doc = new Y.Doc({ gc: false }); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(beforeDoc)); + const attributions = Y.createContentMap(); + // Recorded before the doc's observers run, so a re-render on update sees it. + doc.on("beforeObserverCalls", (tr: any) => { + if (typeof tr.origin === "string" && !tr.insertSet.isEmpty()) { + Y.insertIntoIdMap( + attributions.inserts, + Y.createIdMapFromIdSet(tr.insertSet, [ + Y.createContentAttribute("insert", tr.origin), + ]), + ); + } + }); + const baseDeletes = Y.createDeleteSetFromStructStore(beforeDoc.store); + return { + doc, + attributions, + /** Merge one of `user`'s updates. */ + apply(update: Uint8Array, user: string) { + Y.insertIntoIdMap( + attributions.deletes, + Y.createIdMapFromIdSet( + Y.diffIdSet(Y.decodeUpdate(update).ds, baseDeletes), + [Y.createContentAttribute("delete", user)], + ), + ); + Y.applyUpdate(doc, update, user); + }, + }; +} + // (single- and multi-author suggestion docs are built by // `buildSuggestionScenarioDocs` above.) diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts index e485ed3f87..123b72f860 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -1,4 +1,4 @@ -import { testDocument } from "@shared/testDocument.js"; +import { testDocumentBlocks } from "@shared/testDocumentBlocks.js"; import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; @@ -243,8 +243,8 @@ export const scenarios: SuggestionScenario[] = [ note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", }, { - severity: "low", - note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + severity: "high", + note: "Indenting re-creates Parent and Child as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.", }, ], title: "Nest a bullet under another", @@ -302,8 +302,8 @@ export const scenarios: SuggestionScenario[] = [ id: "delete-nested", feedback: [ { - severity: "low", - note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + severity: "high", + note: "Deleting the only child re-creates Parent as a new block (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows Parent as deleted and inserted again, credited to whoever deleted the child.", }, ], title: "Delete a nested block", @@ -409,6 +409,12 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "type-list-to-paragraph", + feedback: [ + { + severity: "high", + note: "Changing the type re-creates the block as a new one (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", + }, + ], title: "List item → paragraph", category: "Type changes", description: @@ -425,6 +431,12 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "type-paragraph-to-heading", + feedback: [ + { + severity: "high", + note: "Changing the type re-creates the block as a new one (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", + }, + ], title: "Paragraph → heading", category: "Type changes", description: @@ -461,6 +473,48 @@ export const scenarios: SuggestionScenario[] = [ }); }, }, + { + kind: "single", + id: "text-edit-diagram", + title: "Edit a diagram", + category: "Basic text", + description: + "Rename a node in a Mermaid diagram. The source shows the struck-through " + + "and inserted text; the preview renders the suggested diagram.", + initial: [ + { + id: "diagram", + type: "diagram", + content: "graph TD\n A[Draft] --> B[Review]", + }, + ], + apply: (editor) => { + editor.updateBlock("diagram", { + content: "graph TD\n A[Draft] --> B[Publish]", + }); + }, + }, + { + kind: "single", + id: "text-enter-at-heading-start", + feedback: [ + { + severity: "high", + note: "Shows the heading's text deleted and re-inserted in a new block, instead of an empty block inserted above: splitting at the start keeps the block's id on the (now empty) first half.", + }, + ], + title: "Enter at the start of a heading", + category: "Basic text", + description: + "Press Enter at the start of a heading, moving it down below an empty line.", + initial: [ + { id: "h", type: "heading", props: { level: 1 }, content: "Title" }, + ], + apply: (editor) => { + editor.setTextCursorPosition("h", "start"); + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + }, + }, { kind: "single", id: "text-add-bold", @@ -541,6 +595,12 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "move-paragraph-up", + feedback: [ + { + severity: "high", + note: "Moving re-creates the block as a new one at its new place. The diff therefore shows it as deleted at its old place and inserted at its new one, all credited to the mover.", + }, + ], title: "Move paragraph up", category: "Move blocks", description: @@ -556,6 +616,12 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "move-paragraph-with-children", + feedback: [ + { + severity: "high", + note: "Moving re-creates the block, with its child, as a new one at its new place. The diff therefore shows it as deleted at its old place and inserted at its new one, all credited to the mover.", + }, + ], title: "Move paragraph with children", category: "Move blocks", description: @@ -570,7 +636,6 @@ export const scenarios: SuggestionScenario[] = [ }, ], apply: (editor) => editor.moveBlocksUp("parent"), - feedback: [], }, // --- Nesting --- @@ -579,8 +644,8 @@ export const scenarios: SuggestionScenario[] = [ id: "nesting-indent", feedback: [ { - severity: "low", - note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + severity: "high", + note: "Indenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.", }, ], title: "Indent a block", @@ -600,13 +665,13 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "nesting-unindent", - title: "Unindent a block", feedback: [ { - severity: "low", - note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + severity: "high", + note: "Outdenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows N0 and N1 as deleted and inserted again, all credited to whoever outdented.", }, ], + title: "Unindent a block", category: "Nesting", description: "Un-nest N1 out of N0 (outdent) back to a top-level sibling.", initial: [ @@ -627,8 +692,8 @@ export const scenarios: SuggestionScenario[] = [ id: "nesting-change-parent-type", feedback: [ { - severity: "low", - note: "Changing a parent's type deletes the old block and creates a new one — so concurrent edits to the original block can be lost, and the entire new block is attributed to whoever changed the type. A consequence of the schema fix.", + severity: "high", + note: "Changing the type re-creates N0, children included, as a new block (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", }, ], title: "Change type of a parent block", @@ -654,17 +719,11 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-text-alignment", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Center-align", category: "Prop changes", description: "Change a paragraph's text alignment from left to center — a block-level " + - "prop change (no insert/delete marks are generated).", + "prop change highlighted as a formatting change.", initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], apply: (editor) => { const [block] = editor.document; @@ -677,12 +736,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-heading-level", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Demote heading", category: "Prop changes", description: "Change a heading from level 1 to level 2.", @@ -702,12 +755,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-image-width", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Resize image", category: "Prop changes", description: "Change an image's previewWidth (200 → 400).", @@ -729,12 +776,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-image-source", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Change image source", category: "Prop changes", description: "Swap an image's url for a different source.", @@ -1001,6 +1042,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "concurrent", id: "concurrent-indent-cascade", feedback: [ + { + severity: "high", + note: "The diff also shows N0 and N2 as deleted and inserted again: indenting re-creates blocks (see Indent a block).", + }, { severity: "low", note: "Block N1 appears in two places. Previously this concurrency scenario would also not be correctly handled (one of the edits would be dropped).", @@ -1025,10 +1070,199 @@ export const scenarios: SuggestionScenario[] = [ }, { kind: "concurrent", - id: "concurrent-nest-both-under-n0", + id: "concurrent-indent-vs-edit", + feedback: [ + { + severity: "low", + note: "B's edit is lost: A's indent re-creates N1 as a new block, which doesn't have B's concurrent edit.", + }, + { + severity: "high", + note: "The diff also shows N0 and N1 as deleted and inserted again, all credited to A: indenting re-creates blocks (see Indent a block).", + }, + ], + title: "Indent a block vs edit its text", + category: "Nesting", + description: "A indents N1 while B types at the end of N1.", + initial: [ + { id: "n0", type: "paragraph", content: "N0" }, + { id: "n1", type: "paragraph", content: "N1" }, + ], + applyA: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.setTextCursorPosition("n1", "end"); + editor.insertInlineContent(" edited"); + }, + }, + { + kind: "concurrent", + id: "concurrent-nest-into-moved-block", + feedback: [ + { + severity: "high", + note: "The diff also shows R and B1–B3 as deleted and inserted again: indenting and moving re-create blocks.", + }, + { + severity: "low", + note: "Q appears twice: B's indent moves a copy of Q under R, and A's nesting replaces the original Q with another copy holding B1–B3.", + }, + ], + title: "Nest blocks into a block that is moved", + category: "Nesting", + description: "A nests B1–B3 under Q while B nests Q under R.", + initial: [ + { id: "r", type: "paragraph", content: "R" }, + { id: "q", type: "paragraph", content: "Q" }, + { id: "b1", type: "paragraph", content: "B1" }, + { id: "b2", type: "paragraph", content: "B2" }, + { id: "b3", type: "paragraph", content: "B3" }, + ], + applyA: (editor) => { + editor.setSelection("b1", "b3"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.setTextCursorPosition("q", "start"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-parent-type-vs-child-edit", + feedback: [ + { + severity: "high", + note: "The diff also shows Parent and Child as deleted and inserted again, all credited to A: changing the type re-creates blocks (see Change type of a parent block).", + }, + { + severity: "low", + note: "B's edit is lost. Changing the parent's type replaces the parent block with a copy, children included, which doesn't have B's concurrent edit.", + }, + ], + title: "Change a parent's type vs edit its child", + category: "Nesting", + description: + "A changes a parent paragraph to a heading while B types in its child.", + initial: [ + { + id: "p", + type: "paragraph", + content: "Parent", + children: [{ id: "c", type: "paragraph", content: "Child" }], + }, + ], + applyA: (editor) => { + editor.updateBlock("p", { type: "heading", props: { level: 2 } }); + }, + applyB: (editor) => { + editor.setTextCursorPosition("c", "end"); + editor.insertInlineContent(" edited by B"); + }, + }, + { + kind: "concurrent", + id: "concurrent-move-into-deleted-block", + feedback: [ + { + severity: "low", + note: "X is lost: B's move inserts a copy into Parent, which A deletes.", + }, + { + severity: "high", + note: "Versioning shows X as deleted by B, though B only moved it. To be fixed by #3166.", + }, + ], + title: "Move a block into a block that is deleted", + category: "Nesting", + description: "A deletes Parent while B moves X into it.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "x", type: "paragraph", content: "X" }, + { id: "next", type: "paragraph", content: "Next" }, + ], + applyA: (editor) => { + editor.removeBlocks(["parent"]); + }, + applyB: (editor) => { + editor.setTextCursorPosition("x"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-delete-parent-vs-child-type", feedback: [ { severity: "info", + note: "B's type change is lost with Parent, which A deleted. Versioning shows Parent and Child as deleted by A, who deleted them.", + }, + ], + title: "Delete a parent vs change its child's type", + category: "Nesting", + description: "A deletes Parent while B turns its child into a heading.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "next", type: "paragraph", content: "Next" }, + ], + applyA: (editor) => { + editor.removeBlocks(["parent"]); + }, + applyB: (editor) => { + editor.updateBlock("child", { type: "heading" }); + }, + }, + { + kind: "concurrent", + id: "concurrent-delete-parent-vs-child-edit", + feedback: [ + { + severity: "info", + note: "B's text is lost with Parent, which A deleted. It is in neither version, so Versioning doesn't show it. When B's text is in the earlier version, Versioning credits its deletion to A, which never saw it; the gallery can't set that up (every user starts from the same document).", + }, + ], + title: "Delete a parent vs type in its child", + category: "Nesting", + description: "A deletes Parent while B types at the end of its child.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "next", type: "paragraph", content: "Next" }, + ], + applyA: (editor) => { + editor.removeBlocks(["parent"]); + }, + applyB: (editor) => { + editor.setTextCursorPosition("child", "end"); + editor.insertInlineContent(" by B"); + }, + }, + { + kind: "concurrent", + id: "concurrent-nest-both-under-n0", + feedback: [ + { + severity: "high", + note: "The diff also shows N0 as deleted and inserted twice, credited to A and to B: nesting re-creates it.", + }, + { + severity: "low", note: "In this concurrent editing scenario the N0 block is duplicated. Previously this scenario would likely drop one of the changes, so it's not a regression per se. A better fix for the schema compatibility could resolve this.", }, ], @@ -1062,14 +1296,9 @@ export const scenarios: SuggestionScenario[] = [ title: "Text color vs background color", category: "Prop changes", description: - "A sets text color red while B sets background yellow; both apply.", + "A sets text color red while B sets background yellow; both prop changes " + + "merge, each highlighted in its author's color.", initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], applyA: (editor) => { const [block] = editor.document; editor.updateBlock(block, { @@ -1090,8 +1319,12 @@ export const scenarios: SuggestionScenario[] = [ id: "concurrent-heading-vs-list", feedback: [ { - severity: "info", - note: "Both changes are preserved in the merge — A's heading change and B's list-item change both survive.", + severity: "high", + note: "The diff shows the block as deleted and inserted twice, credited to A and to B: each type change re-creates it.", + }, + { + severity: "low", + note: "The block appears twice: A's heading and B's list item are each a copy of it.", }, ], title: "Heading vs list item", @@ -1112,6 +1345,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "concurrent", id: "concurrent-text-vs-heading", feedback: [ + { + severity: "high", + note: "The diff shows the block as deleted and inserted again, all credited to B: changing the type re-creates it.", + }, { severity: "low", note: "User A's content edit is lost — it's overwritten by B's simultaneous block-type change. This is a consequence of the schema fix.", @@ -1192,18 +1429,9 @@ export const scenarios: SuggestionScenario[] = [ { kind: "concurrent", id: "concurrent-table-row-vs-column", - feedback: [ - { - severity: "high", - note: "Crashes — prosemirror-tables' fixTables treats the suggestion-marked table as malformed and feeds y-prosemirror a delta Yjs can't apply (lib0 'Unexpected case'). Confirmed via a fixTables on/off loop (25/25 crashes on, 0/25 off); fix is to block fixTablesKey transactions while suggestions are active, mirroring AIExtension during ai-writing.", - }, - ], title: "Delete row vs add column", category: "Tables", - description: - "A deletes a row while B adds a column — known to crash the merge " + - "(prosemirror-tables fixTables).", - knownCrash: true, + description: "A deletes a row while B adds a column.", initial: [TABLE_2X2], applyA: (editor) => editor.updateBlock("table", { @@ -1226,7 +1454,7 @@ export const scenarios: SuggestionScenario[] = [ title: "Delete column vs add row", feedback: [ { - severity: "high", + severity: "low", note: "Diff seems weird and A2 in wrong place", }, ], @@ -1492,6 +1720,12 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "remove-1-column", + feedback: [ + { + severity: "high", + note: "Removing the column re-creates Left column as a new block outside the columns. The diff therefore shows it as inserted, as if it were new, credited to whoever removed the column.", + }, + ], title: "Remove a column", category: "Multi-column", description: "A two-column layout loses one of its columns.", @@ -1580,7 +1814,9 @@ export const scenarios: SuggestionScenario[] = [ ), }, - // --- Large diffs (the shared testDocument — every block type at once) --- + // Use partial blocks so the editor generates IDs instead of preserving the + // empty IDs in the fully populated exporter test fixture. + // --- Large diffs (the shared test document — every block type at once) --- { kind: "single", id: "large-diff-add-all", @@ -1591,7 +1827,7 @@ export const scenarios: SuggestionScenario[] = [ initial: [{ id: "anchor", type: "paragraph", content: "Document start" }], apply: (editor) => editor.insertBlocks( - testDocument as unknown as GalleryPartialBlock[], + testDocumentBlocks as unknown as GalleryPartialBlock[], "anchor", "after", ), @@ -1609,7 +1845,7 @@ export const scenarios: SuggestionScenario[] = [ category: "Large diffs", description: "Remove every block of the shared test document, leaving a single paragraph — a stress test for large diffs.", - initial: testDocument as unknown as GalleryPartialBlock[], + initial: testDocumentBlocks as unknown as GalleryPartialBlock[], apply: (editor) => editor.replaceBlocks(editor.document, [ { type: "paragraph", content: "(all content removed)" }, diff --git a/examples/07-collaboration/14-suggestion-gallery/src/style.css b/examples/07-collaboration/14-suggestion-gallery/src/style.css index 68dae69154..034b54a5fd 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/style.css +++ b/examples/07-collaboration/14-suggestion-gallery/src/style.css @@ -1,4 +1,13 @@ .bn-gallery { + color-scheme: light dark; + --gallery-text: light-dark(#333, #e0e0e0); + --gallery-muted: light-dark(#666, #aaa); + --gallery-border: light-dark(#e6e6e6, #484848); + --gallery-surface: light-dark(#fafafa, #2e2e2e); + --gallery-hover: light-dark(#f2f2f2, #383838); + --gallery-selected: light-dark(#e7f1ff, #193b59); + --gallery-accent: light-dark(#1971c2, #91caff); + color: var(--gallery-text); display: grid; grid-template-columns: 240px 1fr; gap: 16px; @@ -9,7 +18,7 @@ .bn-gallery-sidebar { overflow-y: auto; - border-right: 1px solid #e6e6e6; + border-right: 1px solid var(--gallery-border); padding-right: 12px; } @@ -17,7 +26,7 @@ font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; - color: #888; + color: var(--gallery-muted); margin: 0 0 12px; } @@ -28,7 +37,7 @@ .bn-gallery-category-label { font-size: 12px; font-weight: 600; - color: #aaa; + color: var(--gallery-muted); margin-bottom: 4px; } @@ -42,20 +51,22 @@ background: transparent; cursor: pointer; font-size: 14px; - color: #333; + color: var(--gallery-text); } .bn-gallery-item:hover { - background: #f2f2f2; + background: var(--gallery-hover); } -.bn-gallery-item--active { - background: #e7f1ff; - color: #1971c2; +.bn-gallery-item--active, +.bn-gallery-item--active:hover { + background: var(--gallery-selected); + color: var(--gallery-accent); font-weight: 600; } .bn-gallery-main { + min-width: 0; overflow-y: auto; } @@ -69,7 +80,7 @@ .bn-gallery-modes { display: inline-flex; - border: 1px solid #d8d8d8; + border: 1px solid var(--gallery-border); border-radius: 8px; overflow: hidden; flex-shrink: 0; @@ -78,14 +89,14 @@ .bn-gallery-mode { padding: 6px 14px; border: none; - background: #fff; + background: var(--gallery-surface); cursor: pointer; font-size: 14px; - color: #555; + color: var(--gallery-text); } .bn-gallery-mode + .bn-gallery-mode { - border-left: 1px solid #d8d8d8; + border-left: 1px solid var(--gallery-border); } .bn-gallery-mode--active { @@ -94,33 +105,25 @@ font-weight: 600; } -.bn-gallery-editors--three { - grid-template-columns: 1fr 1fr 1fr; -} - -.bn-gallery-editors--four { - grid-template-columns: 1fr 1fr 1fr 1fr; -} - .bn-gallery-title { font-size: 20px; margin: 0 0 4px; } .bn-gallery-description { - color: #666; + color: var(--gallery-muted); margin: 0 0 16px; max-width: 60ch; } .bn-gallery-editors { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; } .bn-gallery-pane { - border: 1px solid #e6e6e6; + border: 1px solid var(--gallery-border); border-radius: 8px; padding: 8px; min-width: 0; @@ -129,16 +132,16 @@ .bn-gallery-pane-label { font-size: 12px; font-weight: 600; - color: #888; + color: var(--gallery-muted); padding: 4px 8px; } .bn-gallery-feedback { - border: 1px solid #ececec; + border: 1px solid var(--gallery-border); border-radius: 8px; padding: 10px 12px; margin-bottom: 16px; - background: #fafafa; + background: var(--gallery-surface); } .bn-gallery-feedback-title { @@ -146,7 +149,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #888; + color: var(--gallery-muted); margin-bottom: 6px; } @@ -156,13 +159,13 @@ align-items: baseline; font-size: 13px; line-height: 1.45; - color: #444; + color: var(--gallery-text); padding: 5px 0 5px 8px; border-left: 3px solid transparent; } .bn-gallery-feedback-item + .bn-gallery-feedback-item { - border-top: 1px solid #efefef; + border-top: 1px solid var(--gallery-border); } .bn-gallery-feedback-item--high { @@ -194,10 +197,28 @@ } .bn-gallery-feedback-item--info { - border-left-color: #1971c2; + border-left-color: var(--gallery-accent); } .bn-gallery-feedback-item--info .bn-gallery-feedback-badge { - background: #e7f1ff; - color: #1971c2; + background: var(--gallery-selected); + color: var(--gallery-accent); +} + +@media (max-width: 700px) { + .bn-gallery { + grid-template-columns: 1fr; + height: auto; + } + + .bn-gallery-sidebar { + max-height: 240px; + border-right: none; + border-bottom: 1px solid var(--gallery-border); + padding-bottom: 12px; + } + + .bn-gallery-header { + flex-wrap: wrap; + } } diff --git a/examples/08-extensions/02-versioning/.bnexample.json b/examples/08-extensions/02-versioning/.bnexample.json index c7fc1ec4a4..75b43cbc01 100644 --- a/examples/08-extensions/02-versioning/.bnexample.json +++ b/examples/08-extensions/02-versioning/.bnexample.json @@ -4,7 +4,8 @@ "author": "yousefed", "tags": ["Extension"], "dependencies": { - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6" + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-14", + "react-icons": "^5.5.0" } } diff --git a/examples/08-extensions/02-versioning/README.md b/examples/08-extensions/02-versioning/README.md index 7d018afd9b..1f47b5df41 100644 --- a/examples/08-extensions/02-versioning/README.md +++ b/examples/08-extensions/02-versioning/README.md @@ -1,5 +1,5 @@ # In-Memory Versioning -This example shows how to use the `VersioningExtension` without any collaboration layer (no Yjs required). Snapshots are stored in memory using ProseMirror JSON. +This example shows how to use `InMemoryVersioningExtension` without a collaboration layer. It seeds history with ProseMirror document JSON, which the extension converts to immutable documents using the editor's schema. `initialVersions` also accepts BlockNote JSON as arrays of partial blocks. -**Try it out:** Edit the document, then use the Version History sidebar to save snapshots, preview older versions, rename them, and restore them. You can hide the sidebar with the close button and reopen it with the "History" button. +The sidebar opens on a document with a few versions already in its history, including an automatic unnamed version, so you can preview them, compare them, rename them, restore them, and try the named-only filter right away. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button. diff --git a/examples/08-extensions/02-versioning/package.json b/examples/08-extensions/02-versioning/package.json index 46b9bb8380..82742b07f4 100644 --- a/examples/08-extensions/02-versioning/package.json +++ b/examples/08-extensions/02-versioning/package.json @@ -20,8 +20,9 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6" + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-14", + "react-icons": "^5.5.0" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/08-extensions/02-versioning/src/App.tsx b/examples/08-extensions/02-versioning/src/App.tsx index ce6c388233..efd26ad042 100644 --- a/examples/08-extensions/02-versioning/src/App.tsx +++ b/examples/08-extensions/02-versioning/src/App.tsx @@ -1,46 +1,62 @@ +import { RenderInPortalElement, useCreateBlockNote } from "@blocknote/react"; import "@blocknote/core/fonts/inter.css"; import { - VersioningExtension, - createInMemoryVersioningAdapter, + InMemoryVersioningExtension, + type LocalVersioningOptions, + type VersioningController, } from "@blocknote/core/extensions"; import { DiffVersioningExtension } from "@blocknote/core/y"; import { - BlockNoteViewEditor, - useCreateBlockNote, - useExtensionState, + DefaultVersionMenuItems, + useVersionSnapshot, + VersionMenu, + VersionMenuItem, VersioningSidebar, -} from "@blocknote/react"; +} from "@blocknote/react/versioning"; +import { RiFileCopyLine } from "react-icons/ri"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useState } from "react"; +import { DAY_MS, LIVE_DOCUMENT, SAMPLE_HISTORY } from "./sampleVersions"; import "./style.css"; +const historyOptions: LocalVersioningOptions = { + initialVersions: SAMPLE_HISTORY.map((version) => ({ + name: version.name, + createdAt: Date.now() - version.daysAgo * DAY_MS, + // These samples have flat blocks with plain text. ProseMirror JSON wraps + // each block in a blockContainer, inside the document's blockGroup. + content: { + type: "doc", + content: [ + { + type: "blockGroup", + content: version.blocks.map((block) => ({ + type: "blockContainer", + attrs: { id: block.id }, + content: [ + { + type: block.type, + attrs: block.props, + content: block.content + ? [{ type: "text", text: block.content }] + : [], + }, + ], + })), + }, + ], + }, + })), +}; + export default function App() { - // `createInMemoryVersioningAdapter` is passed as a factory function. The - // VersioningExtension will call it with the editor instance once it's ready. + // Each editor owns its history, seeded here with a few saved documents. const editor = useCreateBlockNote({ - initialContent: [ - { - type: "heading", - content: "In-Memory Versioning Example", - props: { level: 2 }, - }, - { - type: "paragraph", - content: - "This example demonstrates versioning without any collaboration layer. " + - "Snapshots are stored in memory using ProseMirror JSON — no Yjs required.", - }, - { - type: "paragraph", - content: - "Try editing this document, then use the Version History sidebar to " + - "save snapshots. You can preview and restore older versions.", - }, - ], + initialContent: LIVE_DOCUMENT, extensions: [ - VersioningExtension(createInMemoryVersioningAdapter), + InMemoryVersioningExtension(historyOptions), // Opt into rendering version diffs: when comparing two versions the // sidebar shows insertions/deletions as attributed marks. Without this // extension the in-memory versioning falls back to a plain document swap. @@ -48,41 +64,63 @@ export default function App() { ], }); - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { - editor, - }); - const [showSidebar, setShowSidebar] = useState(true); + const [sidebarPanel, setSidebarPanel] = useState(null); return ( -
- -
-
- - {!showSidebar && ( - - )} -
- {showSidebar && ( -
- setShowSidebar(false)} - /> -
- )} -
+
+ {/* No `editable` prop: the sidebar makes the editor read-only for as + long as it's open, and restores it on close. */} + + {!showSidebar && ( + + )} + {showSidebar && sidebarPanel && ( + + setShowSidebar(false)} + // Extend the row menu by composing it: the default items plus + // an app-specific one. Order is yours to choose. + snapshotMenu={ + + + + + } + /> + + )} + {showSidebar &&
}
); } + +/** + * An application-specific row action. `useVersionSnapshot()` hands it the row + * it was rendered in, so it needs no props — the sidebar knows nothing about it. + */ +function MakeCopyItem() { + const { snapshot, isCurrent } = useVersionSnapshot(); + + return ( + } + onClick={() => { + window.alert( + `Would copy ${isCurrent ? "the current version" : (snapshot.name ?? new Date(snapshot.createdAt).toLocaleString())} into a new document.`, + ); + }} + > + Make a copy + + ); +} diff --git a/examples/08-extensions/02-versioning/src/sampleVersions.ts b/examples/08-extensions/02-versioning/src/sampleVersions.ts new file mode 100644 index 0000000000..7a9b5a0257 --- /dev/null +++ b/examples/08-extensions/02-versioning/src/sampleVersions.ts @@ -0,0 +1,114 @@ +import type { PartialBlock } from "@blocknote/core"; + +export const DAY_MS = 24 * 60 * 60 * 1000; + +export type SampleVersion = { + name?: string; + /** How long ago the version was saved. */ + daysAgo: number; + blocks: SampleBlock[]; +}; + +// Stable ids let previews show edits to the same blocks across versions. +type SampleBlock = PartialBlock & { + id: string; + type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem"; + content: string; +}; + +function updateContent(blocks: SampleBlock[], updates: Record) { + return blocks.map((block) => ({ + ...block, + content: updates[block.id] ?? block.content, + })); +} + +const firstDraft: SampleBlock[] = [ + { + id: "title", + type: "heading", + props: { level: 2 }, + content: "Launch plan: Notes 2.0", + }, + { + id: "goal", + type: "paragraph", + content: + "Goal: ship the new editor to every workspace before the end of the quarter.", + }, + { + id: "milestones", + type: "heading", + props: { level: 3 }, + content: "Milestones", + }, + { + id: "m1", + type: "bulletListItem", + content: "Beta with five design partners", + }, + { id: "m3", type: "bulletListItem", content: "Public release" }, +]; + +const addedDates = updateContent( + [ + ...firstDraft.slice(0, -1), + { + id: "m2", + type: "bulletListItem", + content: "Fix the ten most-reported beta issues", + }, + ...firstDraft.slice(-1), + ], + { + goal: "Goal: ship the new editor to every workspace before the end of September.", + m1: "Beta with five design partners (June)", + m3: "Public release (September)", + }, +); + +const marketingReview: SampleBlock[] = [ + ...updateContent(addedDates, { m3: "Public release (September 15)" }), + { + id: "announcement", + type: "heading", + props: { level: 3 }, + content: "Announcement", + }, + { + id: "announcement-text", + type: "paragraph", + content: + "The blog post and changelog entry go out on release day. The newsletter follows a week later.", + }, +]; + +/** Saved versions, oldest first. */ +export const SAMPLE_HISTORY: SampleVersion[] = [ + { name: "First draft", daysAgo: 9, blocks: firstDraft }, + { daysAgo: 6, blocks: addedDates }, + { name: "Marketing review", daysAgo: 2, blocks: marketingReview }, +]; + +/** The newest version with unsaved edits to compare against. */ +export const LIVE_DOCUMENT: PartialBlock[] = [ + ...updateContent(marketingReview, { + goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.", + }), + { + id: "questions", + type: "heading", + props: { level: 3 }, + content: "Open questions", + }, + { + id: "q1", + type: "numberedListItem", + content: "Do we keep the old editor available as a fallback?", + }, + { + id: "q2", + type: "numberedListItem", + content: "Who owns the migration guide?", + }, +]; diff --git a/examples/08-extensions/02-versioning/src/style.css b/examples/08-extensions/02-versioning/src/style.css index 1b4f8812fb..cb1a60f371 100644 --- a/examples/08-extensions/02-versioning/src/style.css +++ b/examples/08-extensions/02-versioning/src/style.css @@ -6,43 +6,30 @@ height: calc(100vh - 20px); } -.wrapper > .bn-container { - margin: 0; - max-width: none; - padding: 0; -} - .layout { display: flex; gap: 0; height: calc(100vh - 20px); } -.editor-panel { +.layout > .bn-container { flex: 1; - height: calc(100vh - 20px); - min-width: 0; - overflow: auto; - position: relative; -} - -.editor-panel .bn-container { height: calc(100vh - 20px); margin: 0; max-width: none; + min-width: 0; + overflow: auto; padding: 0; + position: relative; } -.editor-panel .bn-editor { +.layout > .bn-container .bn-editor { height: calc(100vh - 20px); overflow: auto; } /* The history panel sits flush against the editor with a subtle divider. */ .sidebar-section { - background-color: var(--bn-colors-editor-background); - border-left: 1px solid var(--bn-colors-border); - box-shadow: -6px 0 16px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; height: calc(100vh - 20px); @@ -50,8 +37,18 @@ width: 350px; } -.dark .sidebar-section { - border-left-color: #2c2c2c; +/* `RenderInPortalElement` creates a themed root *inside* the application-owned + slot. Theme variables live on that root, not on its parent. */ +.sidebar-section > .bn-root { + background-color: var(--bn-colors-editor-background); + border-left: 1px solid var(--bn-colors-border); + box-shadow: -6px 0 16px rgba(0, 0, 0, 0.05); + display: flex; + flex: 1; + min-height: 0; +} + +.sidebar-section > .bn-root.dark { box-shadow: -6px 0 16px rgba(0, 0, 0, 0.3); } diff --git a/examples/09-ai/04-with-collaboration/.bnexample.json b/examples/09-ai/04-with-collaboration/.bnexample.json index 83bed82fe4..920c3512a5 100644 --- a/examples/09-ai/04-with-collaboration/.bnexample.json +++ b/examples/09-ai/04-with-collaboration/.bnexample.json @@ -8,6 +8,6 @@ "@mantine/core": "^9.0.2", "ai": "^6.0.5", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/09-ai/04-with-collaboration/package.json b/examples/09-ai/04-with-collaboration/package.json index ac18052d0c..de496c1c07 100644 --- a/examples/09-ai/04-with-collaboration/package.json +++ b/examples/09-ai/04-with-collaboration/package.json @@ -23,7 +23,7 @@ "@blocknote/xl-ai": "latest", "ai": "^6.0.5", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/09-ai/05-manual-execution/.bnexample.json b/examples/09-ai/05-manual-execution/.bnexample.json index 890b2909fe..7c2ebf433b 100644 --- a/examples/09-ai/05-manual-execution/.bnexample.json +++ b/examples/09-ai/05-manual-execution/.bnexample.json @@ -8,6 +8,6 @@ "@mantine/core": "^9.0.2", "ai": "^6.0.5", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" } } diff --git a/examples/09-ai/05-manual-execution/package.json b/examples/09-ai/05-manual-execution/package.json index b8c1893631..d072af5a94 100644 --- a/examples/09-ai/05-manual-execution/package.json +++ b/examples/09-ai/05-manual-execution/package.json @@ -23,7 +23,7 @@ "@blocknote/xl-ai": "latest", "ai": "^6.0.5", "y-partykit": "^0.0.25", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx b/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx index 183cfd2511..1fa318598b 100644 --- a/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx +++ b/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx @@ -1,8 +1,5 @@ -import { - BlockNoteEditor, - SideMenuExtension, - SuggestionMenu, -} from "@blocknote/core"; +import { BlockNoteEditor } from "@blocknote/core"; +import { SideMenuExtension, SuggestionMenu } from "@blocknote/core/extensions"; import "@blocknote/core/fonts/inter.css"; import "@blocknote/core/style.css"; import { useEffect, useRef } from "react"; diff --git a/packages/ariakit/src/components.ts b/packages/ariakit/src/components.ts index d97a129f3f..ed0c9e28d0 100644 --- a/packages/ariakit/src/components.ts +++ b/packages/ariakit/src/components.ts @@ -39,6 +39,8 @@ import { Comment } from "./comments/Comment.js"; import { Editor } from "./comments/Editor.js"; import { Badge, BadgeGroup } from "./badge/Badge.js"; import { + Header as VersioningHeader, + Name as VersioningName, Sidebar as VersioningSidebar, Snapshot as VersioningSnapshot, } from "./versioning/Versioning.js"; @@ -94,7 +96,12 @@ export const components: Components = { }, Versioning: { Sidebar: VersioningSidebar, + Header: VersioningHeader, + Name: VersioningName, Snapshot: VersioningSnapshot, + // The sidebar's loader is the same dots/spinner as the suggestion menu's — + // one spinner per UI package, not one per feature. + Loader: SuggestionMenuLoader, }, Generic: { Badge: { diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 7a34fa67d2..64f1e77e5a 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -94,8 +94,16 @@ export const MenuItem = forwardRef< HTMLDivElement, ComponentProps["Generic"]["Menu"]["Item"] >((props, ref) => { - const { className, children, icon, checked, subTrigger, onClick, ...rest } = - props; + const { + className, + children, + icon, + checked, + disabled, + subTrigger, + onClick, + ...rest + } = props; assertEmpty(rest); @@ -115,6 +123,7 @@ export const MenuItem = forwardRef< className={mergeCSSClasses("bn-ak-menu-item", className || "")} ref={ref} onClick={onClick} + disabled={disabled} > {icon} {children} @@ -128,6 +137,7 @@ export const MenuItem = forwardRef< className={mergeCSSClasses("bn-ak-menu-item", className || "")} ref={ref} onClick={onClick} + disabled={disabled} // How-to-test: with hover focus on, tapping a color focuses the menu through the tap's compat mousemove and closes the keyboard (covered by skinFocus, android, ariakit: "picking from the colors menu leaves focus in the editor"). focusOnHover={!preventFocusOnOpen} // How-to-test: without the tap guard, tapping a color focuses the item and closes the keyboard (covered by the same case). diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 0a7ad05825..f603bd7806 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -256,6 +256,294 @@ color: hsl(204 20% 100%); } +/* Version history */ +.bn-ariakit .bn-versioning-sidebar { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-inline: 16px; +} + +.bn-ariakit .bn-versioning-sidebar-header { + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: space-between; + padding-block: 16px 8px; +} + +.bn-ariakit .bn-versioning-sidebar-header-title, +.bn-ariakit .bn-versioning-sidebar-header-actions { + align-items: center; + display: flex; + gap: 6px; +} + +.bn-ariakit .bn-versioning-sidebar-header-actions { + gap: 4px; +} + +.bn-ariakit .bn-versioning-sidebar-title { + color: hsl(204 4% 0%); + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.bn-ariakit .bn-versioning-sidebar-title:where(.dark, .dark *) { + color: hsl(204 20% 100%); +} + +.bn-ariakit .bn-versioning-sidebar-list { + display: flex; + flex: 1; + flex-direction: column; + margin: -4px -4px 0; + min-height: 0; + overflow-y: auto; + padding: 4px 4px 16px; +} + +.bn-ariakit .bn-versioning-sidebar-loading { + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: center; + padding-block: 24px; +} + +.bn-ariakit .bn-versioning-sidebar-empty, +.bn-ariakit .bn-versioning-sidebar-error { + flex-shrink: 0; + font-size: 13px; + padding-block: 12px; + text-align: center; +} + +.bn-ariakit .bn-versioning-sidebar-empty { + color: hsl(204 4% 40%); +} + +.bn-ariakit .bn-versioning-sidebar-empty:where(.dark, .dark *) { + color: hsl(204 8% 70%); +} + +.bn-ariakit .bn-versioning-sidebar-error { + color: hsl(4 72% 40%); + padding-block: 8px; + text-align: start; +} + +.bn-ariakit .bn-versioning-sidebar-error:where(.dark, .dark *) { + color: hsl(4 85% 70%); +} + +.bn-ariakit .bn-snapshot { + cursor: pointer; + gap: 6px; + margin-bottom: 4px; + overflow: visible; + padding: 12px 14px; + transition: + background-color 0.12s ease, + border-color 0.12s ease; + width: 100%; +} + +.bn-ariakit .bn-snapshot:hover { + background-color: hsl(204 20% 96%); +} + +.bn-ariakit .bn-snapshot:hover:where(.dark, .dark *) { + background-color: hsl(204 4% 22%); +} + +.bn-ariakit .bn-snapshot:focus-visible { + outline: 2px solid hsl(204 100% 40%); + outline-offset: 2px; +} + +.bn-ariakit .bn-snapshot.selected:focus-visible { + outline-color: white; + outline-offset: -4px; +} + +.bn-ariakit .bn-snapshot-title-row { + align-items: center; + display: flex; + gap: 6px; + min-height: 20px; + min-width: 0; + padding-right: 20px; +} + +.bn-ariakit .bn-snapshot-name { + color: inherit; + font-size: 14px; + font-weight: 700; + line-height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-ariakit .bn-snapshot-name-sizer { + display: inline-grid; + grid-template-columns: minmax(0, max-content); + margin: -2px -5px; + max-width: calc(100% + 10px); + min-width: 0; +} + +.bn-ariakit .bn-snapshot-name-sizer::after { + border: 1px solid transparent; + content: attr(data-value) " "; + font-size: 14px; + font-weight: 700; + grid-area: 1 / 1; + line-height: 20px; + /* Keep the hidden measurement text out of the version list's scroll area. */ + overflow: hidden; + padding: 1px 4px; + visibility: hidden; + white-space: pre; +} + +.bn-ariakit .bn-snapshot-name-sizer > .bn-snapshot-name { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + grid-area: 1 / 1; + margin: 0; + min-width: 0; + padding: 1px 4px; + width: 100%; +} + +.bn-ariakit .bn-snapshot-name::placeholder { + color: inherit; + opacity: 1; +} + +.bn-ariakit .bn-snapshot-name-sizer > .bn-snapshot-name:hover { + text-decoration: underline dotted; + text-underline-offset: 3px; +} + +.bn-ariakit .bn-snapshot-name-sizer > .bn-snapshot-name:focus { + border-color: hsl(204 100% 40%); + outline: none; + text-decoration: none; +} + +.bn-ariakit .bn-snapshot.selected .bn-snapshot-name:focus { + background-color: rgb(255 255 255 / 0.15); + border-color: rgb(255 255 255 / 0.6); +} + +.bn-ariakit .bn-snapshot-body { + display: flex; + flex-direction: column; + font-size: 13px; + gap: 2px; +} + +.bn-ariakit .bn-snapshot-date, +.bn-ariakit .bn-snapshot-original-date, +.bn-ariakit .bn-snapshot-secondary-label { + font-size: 13px; + line-height: 1.3; +} + +.bn-ariakit .bn-snapshot-original-date, +.bn-ariakit .bn-snapshot-secondary-label { + color: hsl(204 4% 40%); +} + +.bn-ariakit .bn-snapshot-original-date:where(.dark, .dark *), +.bn-ariakit .bn-snapshot-secondary-label:where(.dark, .dark *) { + color: hsl(204 8% 70%); +} + +.bn-ariakit .bn-snapshot-menu { + opacity: 0; + position: absolute; + right: 8px; + top: 8px; + transition: opacity 0.12s ease; +} + +.bn-ariakit .bn-snapshot:hover .bn-snapshot-menu, +.bn-ariakit .bn-snapshot:focus-visible .bn-snapshot-menu, +.bn-ariakit .bn-snapshot:focus-within .bn-snapshot-menu { + opacity: 1; +} + +.bn-ariakit .bn-snapshot-menu .bn-action-toolbar { + background-color: transparent; + border: none; + border-radius: 0; + padding: 0; +} + +.bn-ariakit .bn-snapshot .bn-snapshot-menu-trigger, +.bn-ariakit .bn-snapshot .bn-snapshot-menu-trigger:hover, +.bn-ariakit .bn-snapshot .bn-snapshot-menu-trigger[data-selected] { + background-color: transparent; + border: none; + box-shadow: none; + height: auto; + min-width: 0; + padding: 2px; +} + +.bn-ariakit .bn-snapshot.selected { + background-color: hsl(204 100% 40%); + color: white; +} + +.bn-ariakit .bn-snapshot.selected:hover:where(.dark, .dark *), +.bn-ariakit .bn-snapshot.selected:where(.dark, .dark *) { + background-color: hsl(204 100% 40%); + color: white; +} + +.bn-ariakit .bn-snapshot.selected .bn-snapshot-date, +.bn-ariakit .bn-snapshot.selected .bn-snapshot-original-date, +.bn-ariakit .bn-snapshot.selected .bn-snapshot-secondary-label, +.bn-ariakit .bn-snapshot.selected .bn-snapshot-menu-trigger { + color: rgb(255 255 255 / 0.9); +} + +.bn-ariakit .bn-snapshot-comparison-source, +.bn-ariakit .bn-snapshot.comparing { + border-color: hsl(204 100% 40%); + box-shadow: 0 0 0 1px hsl(204 100% 40%); +} + +.bn-ariakit .bn-snapshot.comparing { + background-color: hsl(204 100% 40% / 0.08); +} + +.bn-ariakit .bn-snapshot.comparing:where(.dark, .dark *) { + background-color: hsl(204 100% 70% / 0.12); +} + +.bn-ariakit .bn-snapshot-comparing-to { + align-items: center; + color: hsl(204 100% 40%); + display: flex; + font-size: 13px; + font-weight: 600; + gap: 4px; +} + +.bn-ariakit .bn-snapshot-comparing-to:where(.dark, .dark *) { + color: hsl(204 100% 75%); +} + .bn-ak-tab, .bn-ariakit .bn-file-input { background-color: transparent; diff --git a/packages/ariakit/src/toolbar/Toolbar.tsx b/packages/ariakit/src/toolbar/Toolbar.tsx index 46fff5c986..b112315dd1 100644 --- a/packages/ariakit/src/toolbar/Toolbar.tsx +++ b/packages/ariakit/src/toolbar/Toolbar.tsx @@ -10,9 +10,11 @@ export const Toolbar = forwardRef( (props, ref) => { const { className, + "aria-label": ariaLabel, children, onMouseEnter, onMouseLeave, + trapFocus: _trapFocus, variant: _variant, ...rest } = props; @@ -22,6 +24,7 @@ export const Toolbar = forwardRef( return ( ((props, ref) => { - const { className, children, ...rest } = props; - + const { className, children, "aria-label": ariaLabel, ...rest } = props; assertEmpty(rest, false); return ( -
+ {children} -
+ ); }); +export function Header(props: ComponentProps["Versioning"]["Header"]) { + const { className, title, actions, closeAction, ...rest } = props; + assertEmpty(rest, false); + + return ( + + +

{title}

+ {actions} +
+ {closeAction} +
+ ); +} + +export function Name(props: ComponentProps["Versioning"]["Name"]) { + if (props.mode === "display") { + return {props.value}; + } + + return ( + + + + ); +} + export const Snapshot = forwardRef< HTMLDivElement, ComponentProps["Versioning"]["Snapshot"] >((props, ref) => { const { className, - selected, - comparing, + id, + "aria-label": ariaLabel, + state, + tabIndex, + "aria-busy": ariaBusy, onClick, + onKeyDown, + onFocus, actions, - children, + name, + date, + restoredFrom, + secondaryLabel, + comparingLabel, + comparingIcon, ...rest } = props; - assertEmpty(rest, false); + const snapshotStateClass = ( + { + default: "", + selected: "selected", + "comparison-source": "selected bn-snapshot-comparison-source", + "comparison-baseline": "comparing", + } satisfies Record + )[state]; return ( -
- {children} + {comparingLabel && ( +
+ {comparingIcon} + {comparingLabel} +
+ )} +
+
{name}
+ {date &&
{date}
} + {restoredFrom && ( +
{restoredFrom}
+ )} + {secondaryLabel && ( +
{secondaryLabel}
+ )} +
{actions && ( - // Isolate the actions area so clicks on the menu (trigger and items, - // which render inline rather than in a portal) don't bubble to the - // row's select handler.
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} > {actions}
)} -
+ ); }); diff --git a/packages/ariakit/vite.config.ts b/packages/ariakit/vite.config.ts index 2da7210de5..c831f7db10 100644 --- a/packages/ariakit/vite.config.ts +++ b/packages/ariakit/vite.config.ts @@ -13,7 +13,8 @@ export default defineConfig( run: { tasks: { build: { - command: "tsc && vp build", + command: + "tsc --project tsconfig.json --composite false --incremental false --rootDir . && vp build", input: [ ...buildCacheInputs("packages/ariakit"), { pattern: "!**/*.tsbuildinfo", base: "workspace" }, diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 7cc905f662..4620607864 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,5 +1,5 @@ import type { CodeBlockOptions } from "@blocknote/core"; -import { SyntaxHighlightingExtension } from "@blocknote/core"; +import { SyntaxHighlightingExtension } from "@blocknote/core/extensions"; import { createHighlighter } from "./shiki.bundle.js"; /** diff --git a/packages/code-block/vite.config.ts b/packages/code-block/vite.config.ts index 58380838a9..8d54a69e7f 100644 --- a/packages/code-block/vite.config.ts +++ b/packages/code-block/vite.config.ts @@ -12,7 +12,8 @@ export default defineConfig( run: { tasks: { build: { - command: "tsc && vp build", + command: + "tsc --project tsconfig.json --composite false --incremental false --rootDir . && vp build", input: [ ...buildCacheInputs("packages/code-block"), { pattern: "!**/*.tsbuildinfo", base: "workspace" }, diff --git a/packages/core/package.json b/packages/core/package.json index 201425fa88..d204eea8e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,7 @@ "@tiptap/pm": "^3.31.3", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", - "lib0": "^1.0.0-rc.34", + "lib0": "1.0.0-rc.36", "prosemirror-highlight": "^0.15.3", "prosemirror-model": "^1.25.11", "prosemirror-state": "^1.4.4", @@ -124,15 +124,15 @@ "vite-plus": "catalog:", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "peerDependencies": { - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-14", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", + "@y/y": "^14.0.0-rc.26", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", - "yjs": "^13.6.27" + "yjs": "^13.6.33" }, "peerDependenciesMeta": { "@y/y": { diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 004b7fd44b..e127c95242 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -18,7 +18,11 @@ import { isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; import { UnreachableCaseError } from "../../util/typescript.js"; -import { getBlockInfoFromNode, getNodeId } from "../getBlockInfoFromPos.js"; +import { + getBlockInfoFromNode, + getNodeId, + isSuggestedDeletionNode, +} from "../getBlockInfoFromPos.js"; import { getBlockCache, getBlockSchema, @@ -26,6 +30,32 @@ import { getStyleSchema, } from "../pmUtil.js"; +/** + * The text of a plain content node. Plain content can't carry marks, so text + * marked as deleted (in a version diff or a suggestion) would merge with its + * replacement (e.g. `x^2` → `y^3` reads `xy^23`) - keep only the result. + * + * Styled text (`contentNodeToInlineContent`) still includes deleted text; see + * the note there. + * + * TODO(suggestion mode): Do NOT keep this behavior when we implement suggestion + * mode. Converting nodes to blocks must preserve pending suggestions, not + * silently read deletions as accepted in `editor.document`, `onChange` or exports. + * We only accept filtering here because plain text content has no good + * representation of inline formatting, including insertion/deletion marks, and + * merging both versions produces invalid preview source. Suggestion mode needs + * to preserve that information and resolve the preview source separately. + */ +function plainContentText(node: Node): string { + let text = ""; + node.forEach((child) => { + if (!isSuggestedDeletionNode(child)) { + text += child.textContent; + } + }); + return text; +} + /** * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent */ @@ -144,6 +174,12 @@ export function contentNodeToInlineContent< const content: InlineContent[] = []; let currentContent: InlineContent | undefined = undefined; + // NOTE: unlike plain content (`plainContentText`), text marked as deleted in + // a version diff or suggestion is kept here, merged with its replacement. We + // might want to filter it too, but that changes what `editor.document`, + // `onChange` and exports report while suggestions show, and makes a block + // whose text is all deleted read as empty. + // Most of the logic below is for handling links because in ProseMirror links are marks // while in BlockNote links are a type of inline content contentNode.content.forEach((node) => { @@ -372,7 +408,7 @@ export function nodeToCustomInlineContent< ) as any; // TODO: is this safe? could we have Links here that are undesired? } else if (icConfig.content === "plain") { // Plain inline content is a single unstyled string. - content = node.textContent as any; + content = plainContentText(node) as any; } else { content = undefined; } @@ -468,7 +504,7 @@ export function nodeToBlock< case "plain": { // Plain content is a single unstyled text item; an empty block is an // empty array, matching inline content. - const text = blockInfo.content.node.textContent; + const text = plainContentText(blockInfo.content.node); content = text.length > 0 ? [{ type: "text", text, styles: {} }] : []; break; } diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index d18a3ffb76..401ed05a03 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -86,13 +86,15 @@ NESTED BLOCKS margin-left: 24px; } -.bn-block-group .bn-block-group > .bn-block-outer { +/* Attribution wrappers use display: contents; descendant selectors also reach + nested blocks inside those wrappers. */ +.bn-block-group .bn-block-group .bn-block-outer { position: relative; } .bn-block-group .bn-block-group - > .bn-block-outer:not([data-prev-depth-changed])::before { + .bn-block-outer:not([data-prev-depth-changed])::before { content: " "; display: inline; position: absolute; @@ -103,7 +105,7 @@ NESTED BLOCKS .bn-block-group .bn-block-group - > .bn-block-outer[data-prev-depth-change="-2"]::before { + .bn-block-outer[data-prev-depth-change="-2"]::before { height: 0; } @@ -202,11 +204,23 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="heading"], +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] > div[data-type="modification"] > .bn-block-content[data-content-type="heading"], +.bn-block-outer:not([data-prev-type]) + > .bn-block + > [data-type="attributes"] + > span + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > :is(ins, del) @@ -264,6 +278,18 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > [data-type="attributes"] + > span + > .bn-block-content[data-content-type="numberedListItem"]::before, .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] @@ -369,82 +395,61 @@ NESTED BLOCKS background-color: var(--bn-colors-hovered-background); } -/* No list nesting */ -.bn-block-outer[data-prev-type="bulletListItem"] - > .bn-block - > .bn-block-content::before { - content: "•"; +/* Keep the marker on the group so it inherits through attribution wrappers + between the group and its blocks. Each group resets it, so a list nested + under a paragraph starts with a disc again. */ +.bn-block-group { + --bn-bullet-marker: "•"; } -.bn-block-outer:not([data-prev-type]) - > .bn-block - > .bn-block-content[data-content-type="bulletListItem"]::before, -.bn-block-outer:not([data-prev-type]) - > .bn-block - > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before, -.bn-block-outer:not([data-prev-type]) - > .bn-block - > :is(ins, del) - > .bn-suggestion-node - .bn-block-content[data-content-type="bulletListItem"]::before { - content: "•"; +[data-content-type="bulletListItem"] ~ .bn-block-group { + --bn-bullet-marker: "◦"; } -/* 1 level of list nesting */ [data-content-type="bulletListItem"] ~ .bn-block-group - > .bn-block-outer[data-prev-type="bulletListItem"] - > .bn-block - > .bn-block-content::before { - content: "◦"; + [data-content-type="bulletListItem"] + ~ .bn-block-group { + --bn-bullet-marker: "▪\FE0E"; } -[data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) +.bn-block-outer[data-prev-type="bulletListItem"] > .bn-block - > .bn-block-content[data-content-type="bulletListItem"]::before, -[data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) + > .bn-block-content::before, +.bn-block-outer:not([data-prev-type]) > .bn-block - > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { - content: "◦"; -} - -/* 2 levels of list nesting */ -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) > .bn-block - > .bn-block-content::before { - content: "▪\FE0E"; -} - -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) > .bn-block + > [data-type="attributes"] + > span > .bn-block-content[data-content-type="bulletListItem"]::before, -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) +.bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { - content: "▪\FE0E"; + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node + .bn-block-content[data-content-type="bulletListItem"]::before { + content: var(--bn-bullet-marker); } /* CODE BLOCKS */ -.bn-block-content[data-content-type="codeBlock"] { +.bn-block-content[data-content-type="codeBlock"], +/* Keep the code surface above suggestion fills and resets in both themes. + Source-popup previews have no direct
 and keep their transparent surface. */
+.bn-root
+  .bn-suggestion-node
+  .bn-block-content[data-content-type="codeBlock"]:has(> pre) {
   position: relative;
 
   background-color: rgb(22 22 22);
@@ -452,6 +457,7 @@ NESTED BLOCKS
   border-radius: 8px;
 }
 .bn-block-content[data-content-type="codeBlock"] > pre {
+  --bn-suggestion-surface: rgb(22 22 22);
   white-space: pre;
   overflow-x: auto;
   margin: 0;
@@ -501,13 +507,13 @@ NESTED BLOCKS
 
 /* Default to dark theme as the code block has a dark background regardless of theme. */
 .shiki {
-  color: var(--shiki-dark);
+  color: var(--bn-suggestion-token-color, var(--shiki-dark));
 }
 .bn-source-block-popup .shiki {
-  color: var(--shiki-light);
+  color: var(--bn-suggestion-token-color, var(--shiki-light));
 }
 .bn-root[data-color-scheme="dark"] .bn-source-block-popup .shiki {
-  color: var(--shiki-dark);
+  color: var(--bn-suggestion-token-color, var(--shiki-dark));
 }
 
 .bn-preview-with-source-popup {
@@ -1096,9 +1102,15 @@ div[data-type="modification"] {
   border-radius: 4px;
 }
 
+/* Lift attribution fills above the dark editor surface. Mixing the already
+   dark author color with black made short insertions nearly invisible. */
 .dark.bn-root ins,
 .dark.bn-root del {
-  background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+  background-color: color-mix(
+    in srgb,
+    var(--user-color-light) 25%,
+    var(--bn-colors-editor-background)
+  );
   color: var(--user-color-light);
 }
 
@@ -1111,13 +1123,19 @@ from the wrapper. The `.bn-root ins, .bn-root del` rules above still style
 serialized/static output, where the wrapper is a real, painted box.
 */
 .bn-suggestion-mark {
+  --bn-suggestion-token-color: currentColor;
   background-color: color-mix(in srgb, var(--user-color-light) 50%, white);
   color: var(--user-color-dark);
   border-radius: 4px;
 }
 
-.dark.bn-root .bn-suggestion-mark {
-  background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+.dark.bn-root .bn-suggestion-mark,
+.bn-block-content[data-content-type="codeBlock"] > pre .bn-suggestion-mark {
+  background-color: color-mix(
+    in srgb,
+    var(--user-color-light) 25%,
+    var(--bn-suggestion-surface, var(--bn-colors-editor-background))
+  );
   color: white;
 }
 
@@ -1138,7 +1156,11 @@ row/cell). Block deletions that *do* wrap blocks are restyled per-block below.
 }
 
 .dark.bn-root .bn-suggestion-node > * {
-  background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
+  background-color: color-mix(
+    in srgb,
+    var(--user-color-light) 25%,
+    var(--bn-colors-editor-background)
+  );
 }
 
 /*
@@ -1155,11 +1177,12 @@ that wrap blocks get a per-block badge below instead.
   display: inline-block;
   margin-right: 6px;
   padding: 0 4px;
+  /* Matches the block-level badge below, so the two read as one label style. */
   font-size: 11px;
-  font-weight: bold;
+  font-weight: 600;
   text-transform: uppercase;
   letter-spacing: 0.04em;
-  line-height: 1.4;
+  line-height: 16px;
   vertical-align: middle;
   /* Use the editor's text color (themed for light/dark) rather than inheriting,
      which would pick up the deleted content's user color. */
@@ -1196,11 +1219,16 @@ apart. It can be nested below `.bn-block-content` (e.g. code wraps it in 
),
 hence the descendant match.
 */
 .bn-suggestion-node--delete .bn-block-content .bn-inline-content {
+  --bn-suggestion-token-color: currentColor;
   color: var(--user-color-dark);
   text-decoration: line-through;
 }
 
-.dark.bn-root .bn-suggestion-node--delete .bn-block-content .bn-inline-content {
+.dark.bn-root .bn-suggestion-node--delete .bn-block-content .bn-inline-content,
+.bn-suggestion-node--delete
+  .bn-block-content[data-content-type="codeBlock"]
+  > pre
+  .bn-inline-content {
   color: var(--user-color-light);
 }
 
@@ -1208,8 +1236,8 @@ hence the descendant match.
 Deleted table cells are a special case: a / has no `.bn-block-content` and
 its text sits in a bare 

(not `.bn-inline-content`), so neither the strikethrough above nor the block card reaches it — and a table row/cell can't -host the "Deleted" card anyway. Treat them like inline deletions instead: strike -the cell text through in the author's color, and suppress the fallback badge. +host the "Deleted" card anyway. Strike the cell text through in the author's +color and suppress badges inside cells, including nested paragraph badges. */ .bn-suggestion-node--delete :is(td, th) p { color: var(--user-color-dark); @@ -1220,7 +1248,8 @@ the cell text through in the author's color, and suppress the fallback badge. color: var(--user-color-light); } -.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before { +.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before, +:is(td, th) .bn-suggestion-node--delete > :first-child::before { content: none; } @@ -1233,16 +1262,56 @@ spans the whole subtree; the media wrapper exists only for files), but it sets only non-collapsing properties — background / radius / padding never depend on the content's intrinsic size, so no block can break. */ +/* Attribute changes use the same card for text blocks as for media blocks. */ +[data-type="attributes"] > .bn-suggestion-node > .bn-block-content, .bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) { + /* The card bleeds this far into the gutters on both sides, so tinting a block + never shifts its content sideways. */ + --bn-suggestion-card-inset: 6px; background-color: color-mix(in srgb, var(--user-color-light) 50%, white); - border-radius: 16px; - padding: 12px; + border-radius: 6px; + /* A hairline in the author's color, so a pale tint still reads as a card. */ + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--user-color-dark) 25%, transparent); + padding: 3px var(--bn-suggestion-card-inset); + margin-left: calc(-1 * var(--bn-suggestion-card-inset)); + width: calc(100% + 2 * var(--bn-suggestion-card-inset)); } +.dark.bn-root + [data-type="attributes"] + > .bn-suggestion-node + > .bn-block-content, .dark.bn-root .bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); +} + +/* Source-backed blocks can carry attribution inside a clipped popup while + their visible preview has no mark. Frame the preview with a subtle diff + color rather than the entire block column. */ +.bn-block-content + .bn-preview-with-source-popup:has(.bn-source-block-popup .bn-suggestion-mark) + .bn-preview-container { + background-color: color-mix(in srgb, #c9efe9 50%, white); + border-radius: 6px; + box-shadow: 0 0 0 1px color-mix(in srgb, #0f6e62 25%, transparent); +} + +.dark.bn-root + .bn-block-content + .bn-preview-with-source-popup:has(.bn-source-block-popup .bn-suggestion-mark) + .bn-preview-container { + background-color: color-mix( + in srgb, + #c9efe9 25%, + var(--bn-colors-editor-background) + ); } /* @@ -1250,11 +1319,13 @@ Media (image/video/audio/file) has an intrinsic width via its `.bn-file-block-content-wrapper`, so the card hugs it instead of spanning the column. Width-less blocks (a divider's `flex: 1`


, a table) keep full width on purpose — `fit-content` would collapse them to nothing, and full width is the -right look for them anyway. This is the only place that touches sizing, and it's +right look for them anyway. Audio previews also need full width: their player and +wrapper both use percentage widths, so shrink-wrapping hides the controls. +This is the only place that touches sizing, and it's gated on a wrapper that only width-bearing blocks have. */ .bn-suggestion-node - .bn-block-content:not(:has(.bn-inline-content)):has( + .bn-block-content:not(:has(.bn-inline-content, .bn-audio)):has( > .bn-file-block-content-wrapper ) { width: fit-content; @@ -1262,11 +1333,12 @@ gated on a wrapper that only width-bearing blocks have. /* A deletion additionally flags the block with the localized "Deleted" label, placed -above the content (out of flow) with extra top padding reserving its row. +above the content (out of flow) with extra top padding reserving its row: the +card's own 3px, the label's 16px line box, and 2px of breathing room under it. */ .bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) { position: relative; - padding: 48px 24px 24px; + padding-top: calc(3px + 16px + 2px); } .bn-suggestion-node--delete @@ -1275,11 +1347,13 @@ above the content (out of flow) with extra top padding reserving its row. /* Sits in the reserved top padding, above the content. Out of flow so it never becomes a flex item beside the block. */ position: absolute; - top: 16px; - left: 24px; - font-size: 18px; - font-weight: 500; - line-height: 1.2; + top: 3px; + left: var(--bn-suggestion-card-inset); + font-size: 11px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.04em; + text-transform: uppercase; /* Use the editor's text color (themed for light/dark) rather than inheriting, which would pick up the suggestion's user color. */ color: var(--bn-colors-editor-text); @@ -1317,7 +1391,11 @@ left untouched so only the dotted underline carries the color. Both the inline .dark.bn-root [data-type="modification"] .bn-suggestion-mark:hover, .dark.bn-root [data-type="modification"] .bn-suggestion-node:hover > * { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); } /* @@ -1330,7 +1408,10 @@ background fill (unlike insertions, which carry a filled highlight). text-decoration: line-through; } -.dark.bn-root .bn-suggestion-mark--delete { +.dark.bn-root .bn-suggestion-mark--delete, +.bn-block-content[data-content-type="codeBlock"] + > pre + .bn-suggestion-mark--delete { background-color: transparent; color: var(--user-color-light); } diff --git a/packages/core/src/editor/BlockNoteEditor.node.test.ts b/packages/core/src/editor/BlockNoteEditor.node.test.ts new file mode 100644 index 0000000000..144d0e9c6e --- /dev/null +++ b/packages/core/src/editor/BlockNoteEditor.node.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { expect, it, vi } from "vite-plus/test"; +import { createExtension } from "./BlockNoteExtension.js"; +import { BlockNoteEditor } from "./BlockNoteEditor.js"; + +it("emits destroy once, including listeners registered during extension initialization", () => { + const onDestroy = vi.fn(); + const LifecycleExtension = createExtension(({ editor }) => { + editor.on("destroy", onDestroy); + return { key: "lifecycle" }; + }); + const editor = BlockNoteEditor.create({ extensions: [LifecycleExtension()] }); + try { + editor.unmount(); + expect(onDestroy).not.toHaveBeenCalled(); + editor._tiptapEditor.destroy(); + expect(onDestroy).toHaveBeenCalledExactlyOnceWith(); + editor._tiptapEditor.destroy(); + expect(onDestroy).toHaveBeenCalledTimes(1); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("can unsubscribe a destroy listener", () => { + const editor = BlockNoteEditor.create(); + const onDestroy = vi.fn(); + const retainedListener = vi.fn(); + editor.on("destroy", retainedListener); + const unsubscribe = editor.on("destroy", onDestroy); + unsubscribe(); + editor._tiptapEditor.destroy(); + expect(onDestroy).not.toHaveBeenCalled(); + expect(retainedListener).toHaveBeenCalledExactlyOnceWith(); +}); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 3390325563..c62029659b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -59,6 +59,10 @@ import { StyleManager, } from "./managers/index.js"; import type { Selection } from "./selectionTypes.js"; +import type { + ExtensionSelection, + ExtensionSelector, +} from "./managers/ExtensionManager/index.js"; import { transformPasted } from "./transformPasted.js"; export type BlockCache< @@ -103,7 +107,7 @@ export interface BlockNoteEditorOptions< dictionary?: Dictionary & Record; /** - * Disable internal extensions (based on keys / extension name) + * Disable internal extensions (based on keys / extension name). * * @note Advanced */ @@ -352,6 +356,8 @@ export class BlockNoteEditor< SSchema extends StyleSchema = DefaultStyleSchema, > extends EventEmitter<{ create: void; + /** Emitted when the editor is permanently destroyed, not on unmount. */ + destroy: void; }> { /** * The underlying prosemirror schema @@ -501,6 +507,8 @@ export class BlockNoteEditor< const tiptapOptions: EditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, + // ReadOnlyExtension owns editability, including the initial application preference. + editable: true, element: null, autofocus: newOptions.autofocus ?? false, extensions: tiptapExtensions, @@ -588,6 +596,9 @@ export class BlockNoteEditor< this._tiptapEditor.on("unmount", () => { this.headless = true; }); + this._tiptapEditor.on("destroy", () => { + this.emit("destroy"); + }); // Initialize managers this._blockManager = new BlockManager(this as any); @@ -680,11 +691,27 @@ export class BlockNoteEditor< } /** - * Remove extension(s) from the editor + * Remove extension(s) and return the removed instance(s) for later registration. + * Removed ProseMirror plugin state is not retained. */ - public unregisterExtension: ExtensionManager["unregisterExtension"] = ( - ...args: Parameters - ) => this._extensionManager.unregisterExtension(...args); + public unregisterExtension( + extension: T, + ): ReturnType> | undefined; + public unregisterExtension( + extension: T, + ): T | undefined; + public unregisterExtension(extensions: ExtensionSelector[]): Extension[]; + public unregisterExtension( + extension: ExtensionSelector, + ): Extension | undefined; + public unregisterExtension( + extension: ExtensionSelection, + ): Extension | Extension[] | undefined; + public unregisterExtension( + extension: ExtensionSelection, + ): Extension | Extension[] | undefined { + return this._extensionManager.unregisterExtension(extension); + } /** * Register extension(s) to the editor @@ -1083,7 +1110,10 @@ export class BlockNoteEditor< } /** - * Makes the editor editable or locks it, depending on the argument passed. + * Sets the application's editable preference. Feature read-only restrictions + * still apply when set to true. + * Plugins can temporarily prevent editing without changing this setting. + * The getter reports whether editing is currently allowed by both. * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 6405465f09..6aeaea0d33 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -196,3 +196,62 @@ For the ShowSelectionPlugin background-color: highlight; padding: 2px 0; } + +/* Preview loading indicator. Override the color and size on the editor. */ +.bn-editor.bn-loading::before { + animation: + bn-loader-rotate 1s linear infinite, + bn-loader-clip 2s linear infinite; + border: calc(5 * var(--bn-loader-size, 1px)) solid + var(--bn-loader-color, currentColor); + border-radius: 50%; + box-sizing: border-box; + display: block; + height: calc(48 * var(--bn-loader-size, 1px)); + width: calc(48 * var(--bn-loader-size, 1px)); +} + +/* Keep the editor's loader visible while scrolling without moving content. */ +.bn-editor.bn-loading > .bn-block-group { + opacity: 0.4; + transition: opacity 0.2s ease; +} + +.bn-editor.bn-loading::before { + content: ""; + margin: 0 auto calc(-48 * var(--bn-loader-size, 1px)); + position: sticky; + top: 16px; + z-index: 1; +} + +@keyframes bn-loader-rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes bn-loader-clip { + 0% { + clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0); + } + 25% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0); + } + 50% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%); + } + 75% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%); + } + 100% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .bn-editor.bn-loading::before { + animation: none; + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%); + } +} diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.browser.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.browser.test.ts new file mode 100644 index 0000000000..9d31e69374 --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.browser.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } +}); + +it("mounts runtime extensions and cleans them up when removed", () => { + let mounts = 0; + let unmounts = 0; + const extension = createExtension(() => ({ + key: "runtime-lifecycle", + mount({ signal }: { signal: AbortSignal }) { + expect(signal.aborted).toBe(false); + mounts++; + return () => { + unmounts++; + }; + }, + })); + const editor = BlockNoteEditor.create(); + const host = document.createElement("div"); + document.body.append(host); + editor.mount(host); + cleanups.push(() => { + editor._tiptapEditor.destroy(); + host.remove(); + }); + editor.registerExtension(extension()); + expect(mounts).toBe(1); + const instance = editor.getExtension(extension)!; + editor.unregisterExtension(extension); + expect(unmounts).toBe(1); + editor.registerExtension(instance); + expect(mounts).toBe(2); + editor.unmount(); + expect(unmounts).toBe(2); + editor.mount(host); + expect(mounts).toBe(3); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts new file mode 100644 index 0000000000..ffa6b973ab --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +import { Plugin, PluginKey } from "prosemirror-state"; +import { Extension as TiptapExtension } from "@tiptap/core"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +const editors: BlockNoteEditor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } +}); + +function rebuildPlugins(editor: BlockNoteEditor) { + // This is the state-only step Tiptap performs on every mount. No view is + // required to prove that its plugin source disagrees with BlockNote's registry. + editor.prosemirrorView.updateState( + editor.prosemirrorState.reconfigure({ + plugins: editor._tiptapEditor.extensionManager.plugins, + }), + ); +} + +describe("runtime extension plugin source", () => { + it("resets selected plugin state without resetting retained plugins", () => { + const key = new PluginKey("reset"); + const retainedKey = new PluginKey("retained"); + const original = { + key: "reset", + prosemirrorPlugins: [ + new Plugin({ + key, + state: { + init: () => 1, + apply: (_tr, value) => value, + }, + }), + ], + }; + const replacement = { + key: "reset", + prosemirrorPlugins: [ + new Plugin({ + key, + state: { + init: () => 2, + apply: (_tr, value) => value, + }, + }), + ], + }; + const editor = BlockNoteEditor.create({ + extensions: [ + () => original, + () => ({ + key: "retained", + prosemirrorPlugins: [ + new Plugin({ + key: retainedKey, + state: { init: () => 99, apply: (_tr, value) => value }, + }), + ], + }), + ], + }); + editors.push(editor); + rebuildPlugins(editor); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Keep content" }, + ]); + editor.replaceExtension(original, replacement); + expect(key.getState(editor.prosemirrorState)).toBe(1); + editor.replaceExtension(replacement, replacement, { + resetPluginStateFor: [key], + }); + expect(key.getState(editor.prosemirrorState)).toBe(2); + expect(retainedKey.getState(editor.prosemirrorState)).toBe(99); + expect(editor.prosemirrorState.doc.textContent).toBe("Keep content"); + rebuildPlugins(editor); + expect(key.getState(editor.prosemirrorState)).toBe(2); + }); + it("does not reinstall a removed extension", () => { + const key = new PluginKey("removed"); + const extension = createExtension(() => ({ + key: "removed", + prosemirrorPlugins: [new Plugin({ key })], + })); + const editor = BlockNoteEditor.create({ extensions: [extension()] }); + editors.push(editor); + rebuildPlugins(editor); + expect(key.get(editor.prosemirrorState)).toBeDefined(); + editor.unregisterExtension(extension); + rebuildPlugins(editor); + expect(key.get(editor.prosemirrorState)).toBeUndefined(); + }); + + it("retains a runtime addition and its state", () => { + const key = new PluginKey("added"); + const plugin = new Plugin({ + key, + state: { + init: () => 0, + apply: (tr, previous) => previous + (tr.docChanged ? 1 : 0), + }, + }); + const editor = BlockNoteEditor.create(); + editors.push(editor); + rebuildPlugins(editor); + editor.registerExtension({ key: "added", prosemirrorPlugins: [plugin] }); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Changed" }, + ]); + expect(key.getState(editor.prosemirrorState)).toBe(1); + rebuildPlugins(editor); + expect(key.get(editor.prosemirrorState)).toBe(plugin); + expect(key.getState(editor.prosemirrorState)).toBe(1); + }); + + it("uses the replacement, not the original plugin", () => { + const key = new PluginKey("replaced"); + const original = { + key: "replaced", + prosemirrorPlugins: [new Plugin({ key })], + }; + const replacement = { + key: "replaced", + prosemirrorPlugins: [new Plugin({ key })], + }; + const editor = BlockNoteEditor.create({ extensions: [() => original] }); + editors.push(editor); + rebuildPlugins(editor); + editor.replaceExtension(original, replacement); + expect(editor.prosemirrorState.plugins.at(-1)).toBe( + replacement.prosemirrorPlugins[0], + ); + rebuildPlugins(editor); + expect(key.get(editor.prosemirrorState)).toBe( + replacement.prosemirrorPlugins[0], + ); + expect(editor.prosemirrorState.plugins.at(-1)).toBe( + replacement.prosemirrorPlugins[0], + ); + }); + + it("keeps runtime additions after low-priority Tiptap plugins", () => { + const original = new Plugin({ key: new PluginKey("low-priority") }); + const added = new Plugin({ key: new PluginKey("runtime") }); + const editor = BlockNoteEditor.create({ + extensions: [ + () => ({ + key: "low-priority", + tiptapExtensions: [ + TiptapExtension.create({ + name: "low-priority", + priority: -1, + addProseMirrorPlugins: () => [original], + }), + ], + }), + ], + }); + editors.push(editor); + rebuildPlugins(editor); + editor.registerExtension({ key: "runtime", prosemirrorPlugins: [added] }); + expect(editor.prosemirrorState.plugins.at(-1)).toBe(added); + rebuildPlugins(editor); + expect(editor.prosemirrorState.plugins.at(-1)).toBe(added); + }); + + it("preserves factory lookup when registering an instance directly", () => { + const extension = createExtension(() => ({ key: "factory", value: 42 })); + const editor = BlockNoteEditor.create({ extensions: [extension()] }); + editors.push(editor); + const instance = editor.getExtension(extension)!; + const removed = editor.unregisterExtension(extension); + expect(removed).toBe(instance); + editor.registerExtension(instance); + expect(editor.getExtension(extension)).toBe(instance); + }); + + it("returns only registered instances once when removing a group", () => { + const first = { key: "first" }; + const second = { key: "second" }; + const editor = BlockNoteEditor.create({ + extensions: [() => first, () => second], + }); + editors.push(editor); + expect( + editor.unregisterExtension([first, first, second, "missing"]), + ).toEqual([first, second]); + expect(editor.unregisterExtension(first)).toBeUndefined(); + }); + + it("accepts callers whose argument can be a key or a group", () => { + function remove(editor: BlockNoteEditor, keys: string | string[]) { + return editor.unregisterExtension(keys); + } + const first = { key: "first" }; + const second = { key: "second" }; + const editor = BlockNoteEditor.create({ + extensions: [() => first, () => second], + }); + editors.push(editor); + expect(remove(editor, "first")).toBe(first); + expect(remove(editor, ["second"])).toEqual([second]); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..0eb62d9e7d 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -21,6 +21,7 @@ import { PlaceholderExtension, PositionMappingExtension, PreviousBlockTypeExtension, + ReadOnlyExtension, ShowSelectionExtension, SideMenuExtension, SourceBlockWithPreviewExtension, @@ -168,6 +169,7 @@ export function getDefaultExtensions( LinkToolbarExtension(options), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), + ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), ShowSelectionExtension(options), SideMenuExtension(options), SourceBlockWithPreviewExtension(), diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5a3929e8cf..34e7ad4e76 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -7,7 +7,7 @@ import { Extension as TiptapExtension, } from "@tiptap/core"; import { keydownHandler } from "@tiptap/pm/keymap"; -import { Plugin, TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js"; import { @@ -30,6 +30,13 @@ import { getDefaultTiptapExtensions, } from "./extensions.js"; +export type ExtensionSelector = + | Extension + | ExtensionFactory + | string + | undefined; +export type ExtensionSelection = ExtensionSelector | ExtensionSelector[]; + export class ExtensionManager { /** * A set of extension keys which are disabled by the options @@ -52,6 +59,7 @@ export class ExtensionManager { * We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered */ private extensionPlugins: Map = new Map(); + private runtimeExtensions = new Set(); /** * Maps an extension key to the set of extension keys that declared it as a * dependency via `blockNoteExtensions`. A sub-extension is a dependency of @@ -68,24 +76,7 @@ export class ExtensionManager { */ editor.onMount(() => { for (const extension of this.extensions) { - // If the extension has an init function, we can initialize it, otherwise, it is already added to the editor - if (extension.mount) { - // We create an abort controller for each extension, so that we can abort the extension when the editor is unmounted - const abortController = new window.AbortController(); - const unmountCallback = extension.mount({ - dom: editor.prosemirrorView.dom, - root: editor.prosemirrorView.root, - signal: abortController.signal, - }); - // If the extension returns a method to unmount it, we can register it to be called when the abort controller is aborted - if (unmountCallback) { - abortController.signal.addEventListener("abort", () => { - unmountCallback(); - }); - } - // Keep track of the abort controller for each extension, so that we can abort it when the editor is unmounted - this.abortMap.set(extension, abortController); - } + this.mountExtension(extension); } }); @@ -122,6 +113,26 @@ export class ExtensionManager { } } + private mountExtension(extension: Extension): void { + if (!extension.mount || this.abortMap.has(extension)) { + return; + } + const controller = new window.AbortController(); + this.abortMap.set(extension, controller); + const cleanup = extension.mount({ + dom: this.editor.prosemirrorView.dom, + root: this.editor.prosemirrorView.root, + signal: controller.signal, + }); + if (cleanup) { + if (controller.signal.aborted) { + cleanup(); + } else { + controller.signal.addEventListener("abort", cleanup, { once: true }); + } + } + } + /** * Register one or more extensions to the editor after the editor is initialized. * @@ -185,14 +196,12 @@ export class ExtensionManager { } // Now that we know that the extension is not disabled, we can add it to the extension factories - if (typeof extension === "function") { - const originalFactory = (instance as any)[originalFactorySymbol] as ( - ...args: any[] - ) => ExtensionFactoryInstance; + const originalFactory = (instance as any)[originalFactorySymbol] as ( + ...args: any[] + ) => ExtensionFactoryInstance; - if (typeof originalFactory === "function") { - this.extensionFactories.set(originalFactory, instance); - } + if (typeof originalFactory === "function") { + this.extensionFactories.set(originalFactory, instance); } this.extensions.push(instance); @@ -243,23 +252,38 @@ export class ExtensionManager { /** * Unregister an extension from the editor * @param toUnregister - The extension to unregister - * @returns void + * @returns The removed instance, or the removed instances for an array. */ + public unregisterExtension( + toUnregister: T, + ): ReturnType> | undefined; + public unregisterExtension( + toUnregister: T, + ): T | undefined; + public unregisterExtension(toUnregister: ExtensionSelector[]): Extension[]; public unregisterExtension( - toUnregister: - | undefined - | string - | Extension - | ExtensionFactory - | (Extension | ExtensionFactory | string | undefined)[], - ): void { - this.replaceExtension(toUnregister, []); + toUnregister: ExtensionSelector, + ): Extension | undefined; + public unregisterExtension( + toUnregister: ExtensionSelection, + ): Extension | Extension[] | undefined; + public unregisterExtension( + toUnregister: ExtensionSelection, + ): Extension | Extension[] | undefined { + const removed = [...new Set(this.resolveExtensions(toUnregister))].filter( + (extension) => this.extensions.includes(extension), + ); + if (removed.length) { + this.replaceExtension(removed, []); + } + return Array.isArray(toUnregister) ? removed : removed[0]; } /** * Atomically replace extension instances in the editor. * @param toUnregister - The extensions to unregister, can be a string key, an extension instance, an extension factory, or an array of any of those * @param toRegister - The extensions to register, can be an extension instance, an extension factory, or an array of any of those + * @param options.resetPluginStateFor - Plugin keys whose state must be initialized afresh instead of carried across the replacement * @returns void */ public replaceExtension( @@ -273,6 +297,7 @@ export class ExtensionManager { | Extension | ExtensionFactoryInstance | (Extension | ExtensionFactoryInstance)[], + options?: { resetPluginStateFor: readonly PluginKey[] }, ): void { // ---- Remove phase (no updatePlugins call) ---- const extensionsToRemove = this.resolveExtensions(toUnregister); @@ -310,6 +335,7 @@ export class ExtensionManager { } }); this.extensionPlugins.delete(extension); + this.runtimeExtensions.delete(extension); if (extension.tiptapExtensions && !didWarnUnregister) { didWarnUnregister = true; @@ -332,6 +358,7 @@ export class ExtensionManager { const pluginsToAdd: Plugin[] = []; for (const extension of registeredExtensions) { + this.runtimeExtensions.add(extension); if (extension?.tiptapExtensions) { // eslint-disable-next-line no-console console.warn( @@ -355,35 +382,44 @@ export class ExtensionManager { ); } - // Nothing to do + // ---- Single atomic plugin update for replacement or an explicit reset ---- if ( - !pluginRefsToRemove.size && - !pluginKeysToRemove.size && - !pluginsToAdd.length + pluginRefsToRemove.size || + pluginKeysToRemove.size || + pluginsToAdd.length || + options?.resetPluginStateFor.length ) { - return; + this.updatePlugins( + (plugins) => [ + ...plugins.filter((plugin) => { + // Fast path: exact reference match + if (pluginRefsToRemove.has(plugin)) { + return false; + } + // Fallback: match by key string (handles cases where plugin instances + // in the state differ from the ones we tracked) + if (pluginKeysToRemove.size) { + const key = (plugin as any).spec?.key; + const keyStr = typeof key === "object" && key ? key.key : key; + if ( + typeof keyStr === "string" && + pluginKeysToRemove.has(keyStr) + ) { + return false; + } + } + return true; + }), + ...pluginsToAdd, + ], + options?.resetPluginStateFor, + ); + } + if (!this.editor.headless) { + for (const extension of registeredExtensions) { + this.mountExtension(extension); + } } - - // ---- Single atomic plugin update ---- - this.updatePlugins((plugins) => [ - ...plugins.filter((plugin) => { - // Fast path: exact reference match - if (pluginRefsToRemove.has(plugin)) { - return false; - } - // Fallback: match by key string (handles cases where plugin instances - // in the state differ from the ones we tracked) - if (pluginKeysToRemove.size) { - const key = (plugin as any).spec?.key; - const keyStr = typeof key === "object" && key ? key.key : key; - if (typeof keyStr === "string" && pluginKeysToRemove.has(keyStr)) { - return false; - } - } - return true; - }), - ...pluginsToAdd, - ]); } /** @@ -391,10 +427,23 @@ export class ExtensionManager { * @param update - A function that takes the current plugins and returns the new plugins * @returns void */ - private updatePlugins(update: (plugins: Plugin[]) => Plugin[]): void { + private updatePlugins( + update: (plugins: Plugin[]) => Plugin[], + resetPluginStateFor?: readonly PluginKey[], + ): void { const currentState = this.editor.prosemirrorState; - - const state = currentState.reconfigure({ + // Reconfigure an intermediate state off-view to drop selected keyed state. + // New plugins initialize before any view hooks can see a torn binding, and + // the view receives only the final, complete plugin set. + const resetKeys = new Set(resetPluginStateFor); + const baseState = resetKeys.size + ? currentState.reconfigure({ + plugins: currentState.plugins.filter( + (plugin) => !plugin.spec.key || !resetKeys.has(plugin.spec.key), + ), + }) + : currentState; + const state = baseState.reconfigure({ plugins: update(currentState.plugins.slice()), }); @@ -443,7 +492,12 @@ export class ExtensionManager { TiptapExtension.create({ name: extension.key, priority, - addProseMirrorPlugins: () => prosemirrorPlugins, + // Removed extensions have no plugins in the map. Replacements and + // re-registrations belong to the runtime wrapper below instead. + addProseMirrorPlugins: () => + this.runtimeExtensions.has(extension) + ? [] + : (this.extensionPlugins.get(extension) ?? []), }), ); } @@ -455,6 +509,19 @@ export class ExtensionManager { } } + // Runtime additions have no construction-time Tiptap wrapper. Like + // registerExtension's plugin update, append their already-created plugins. + tiptapExtensions.push( + TiptapExtension.create({ + name: "blocknote-runtime-extensions", + priority: Number.NEGATIVE_INFINITY, + addProseMirrorPlugins: () => + this.extensions + .filter((extension) => this.runtimeExtensions.has(extension)) + .flatMap((extension) => this.extensionPlugins.get(extension) ?? []), + }), + ); + // Collect all input rules into 1 extension to reduce conflicts tiptapExtensions.push( TiptapExtension.create({ diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index 9dc3eebff2..c6a2edbdcb 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -1,4 +1,5 @@ import { Command, Transaction } from "prosemirror-state"; +import { ReadOnlyExtension } from "../../extensions/ReadOnly/ReadOnly.js"; import type { HistoryExtension } from "../../extensions/History/History.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; @@ -188,13 +189,24 @@ export class StateManager { } return false; } + if (this.editor.headless) { + // No live view while unmounted, so tiptap can't consult plugin props + // (its unmounted view stub reports editable: true). Mirror the + // ReadOnly plugin's `editable` prop directly so the application + // preference and feature restrictions still read back correctly, + // e.g. for static/server-side rendering via block render functions. + const state = this.editor.getExtension(ReadOnlyExtension)?.store.state; + if (state) { + return state.isEditable && state.enabledSet.size === 0; + } + } return this.editor._tiptapEditor.isEditable === undefined ? true : this.editor._tiptapEditor.isEditable; } /** - * Makes the editor editable or locks it, depending on the argument passed. + * Sets the application's editable preference without releasing feature restrictions. * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { @@ -205,9 +217,7 @@ export class StateManager { // not relevant on headless return; } - if (this.editor._tiptapEditor.options.editable !== editable) { - this.editor._tiptapEditor.setEditable(editable); - } + this.editor.getExtension(ReadOnlyExtension)!.setEditable(editable); } /** diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts new file mode 100644 index 0000000000..6cb09d0592 --- /dev/null +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts @@ -0,0 +1,181 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { ReadOnlyExtension } from "./ReadOnly.js"; + +describe("ReadOnlyExtension", () => { + let editor: BlockNoteEditor; + let readOnly: ReturnType>; + + beforeEach(() => { + editor = BlockNoteEditor.create(); + editor.mount(document.createElement("div")); + readOnly = editor.getExtension(ReadOnlyExtension)!; + }); + + afterEach(() => editor.unmount()); + + it("keeps editing disabled until every feature releases its restriction", () => { + readOnly.setReadOnly(true, "preview"); + readOnly.setReadOnly(true, "upload"); + readOnly.setReadOnly(true, "upload"); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "preview"); + readOnly.setReadOnly(false, "unrelated"); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "upload"); + expect(editor.isEditable).toBe(true); + }); + + it("preserves the application's latest editable setting", () => { + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + readOnly.setReadOnly(false, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + }); + + it("notifies transaction subscribers without reporting document changes", () => { + const changes = vi.fn(); + const editableStates: boolean[] = []; + editor.onChange(changes); + editor._tiptapEditor.on("transaction", () => { + editableStates.push(editor.isEditable); + }); + + readOnly.setReadOnly(true, "preview"); + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => !editable)).toBe(true); + editableStates.length = 0; + readOnly.setReadOnly(true, "preview"); + expect(editableStates).toEqual([]); + readOnly.setReadOnly(false, "preview"); + + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => editable)).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("uses editable metadata for both inputs and skips changes that keep editing locked", () => { + const metadata: unknown[] = []; + const changes = vi.fn(); + editor.onChange(changes); + editor._tiptapEditor.on("transaction", ({ transaction }) => { + metadata.push(transaction.getMeta("editable")); + }); + + editor.isEditable = false; + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + metadata.length = 0; + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + readOnly.setReadOnly(true, "upload"); + readOnly.setReadOnly(false, "preview"); + expect(metadata).toEqual([]); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "upload"); + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + expect(editor.isEditable).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("applies initial editability and preserves it across remounts", () => { + editor.unmount(); + editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } }); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + editor.isEditable = false; + editor.unmount(); + editor.mount(document.createElement("div")); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("reports application and feature editability while unmounted", () => { + editor.unmount(); + editor = BlockNoteEditor.create(); + readOnly = editor.getExtension(ReadOnlyExtension)!; + + expect(editor.isEditable).toBe(true); + + editor.isEditable = false; + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + + readOnly.setReadOnly(true, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + readOnly.setReadOnly(false, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + }); + + it("honours editability set before mount", () => { + editor.unmount(); + editor = BlockNoteEditor.create(); + editor.isEditable = false; + expect(editor.isEditable).toBe(false); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("groups application editability changes into the pending transaction", () => { + const transactions = vi.fn(); + const changes = vi.fn(); + editor._tiptapEditor.on("transaction", transactions); + editor.onChange(changes); + + editor.transact(() => { + editor.isEditable = false; + }); + expect(editor.isEditable).toBe(false); + expect(transactions).toHaveBeenCalled(); + expect(changes).not.toHaveBeenCalled(); + + transactions.mockClear(); + editor.transact((tr) => { + tr.insertText("hello", 1); + editor.isEditable = true; + }); + expect(editor.isEditable).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(transactions).toHaveBeenCalled(); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it("composes with pending document and metadata-only transactions", () => { + editor.transact((tr) => { + tr.insertText("hello", 1); + readOnly.setReadOnly(true, "preview"); + }); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(editor.isEditable).toBe(false); + + editor.transact(() => readOnly.setReadOnly(false, "preview")); + expect(editor.isEditable).toBe(true); + }); +}); diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts new file mode 100644 index 0000000000..a15876bbb4 --- /dev/null +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts @@ -0,0 +1,73 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { + createExtension, + createStore, + type ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; + +const PLUGIN_KEY = new PluginKey("bn-read-only"); + +/** Owns application editability and independent feature restrictions. */ +export const ReadOnlyExtension = createExtension( + ({ + editor, + options, + }: ExtensionOptions<{ editable?: boolean } | undefined>) => { + const store = createStore( + { + isEditable: options?.editable ?? true, + enabledSet: new Set(), + }, + { + onUpdate(state, prevState) { + if ( + (state.isEditable && state.enabledSet.size === 0) === + (prevState.isEditable && prevState.enabledSet.size === 0) + ) { + return; + } + if (!editor.headless) { + // Recompute plugin editability and notify UI subscribers without a + // document change. Reuse any transaction already in progress. + editor.transact((tr) => tr.setMeta("editable", true)); + } + }, + }, + ); + + return { + key: "readOnly", + store, + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + editable: () => + store.state.isEditable && store.state.enabledSet.size === 0, + }, + }), + ], + /** Set the application's preference without releasing feature restrictions. */ + setEditable(editable: boolean) { + if (store.state.isEditable === editable) { + return; + } + store.setState({ ...store.state, isEditable: editable }); + }, + /** + * Enable or disable read-only mode for a feature identified by key. + * Passing false releases only that feature's restriction; other features + * and the application's editor.isEditable setting still apply. + * Repeated calls with the same key are idempotent. + */ + setReadOnly(readOnly: boolean, key: string) { + store.setState({ + ...store.state, + enabledSet: readOnly + ? new Set([...store.state.enabledSet, key]) + : new Set([...store.state.enabledSet].filter((k) => k !== key)), + }); + }, + } as const; + }, +); diff --git a/packages/core/src/extensions/Versioning/Versioning.browser.test.ts b/packages/core/src/extensions/Versioning/Versioning.browser.test.ts new file mode 100644 index 0000000000..4253d2b7ba --- /dev/null +++ b/packages/core/src/extensions/Versioning/Versioning.browser.test.ts @@ -0,0 +1,151 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; +import { createLocalVersioning } from "./inMemoryVersioning.js"; +import { createVersioningExtension } from "./Versioning.js"; +import { SCROLL_TO_FIRST_CHANGE_DELAY_MS } from "./scrollToFirstChange.js"; + +const cleanup: Array<() => void> = []; +afterEach(() => { + for (const dispose of cleanup.splice(0)) { + dispose(); + } + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function setup(scrollToFirstChange = true, loadContent?: () => Promise) { + const source = BlockNoteEditor.create({ + initialContent: [ + { id: "paragraph", type: "paragraph", content: "Old text" }, + ], + }); + const before = source.prosemirrorState.doc.toJSON(); + source.updateBlock("paragraph", { content: "Changed text" }); + const after = source.prosemirrorState.doc.toJSON(); + source._tiptapEditor.destroy(); + const Versions = createVersioningExtension((editor) => { + const local = createLocalVersioning(editor, { + initialVersions: [ + { content: before, createdAt: 1 }, + { content: after, createdAt: 2 }, + ], + }); + return { + ...local, + storage: { + ...local.storage, + async getContent(id, signal) { + await loadContent?.(); + return local.storage.getContent(id, signal); + }, + }, + scrollToFirstChange, + }; + }); + const editor = BlockNoteEditor.create({ + initialContent: [ + { id: "paragraph", type: "paragraph", content: "Live text" }, + ], + extensions: [Versions(), DiffVersioningExtension()], + }); + const host = document.createElement("div"); + document.body.append(host); + editor.mount(host); + const mode = editor.getExtension(Versions)!; + cleanup.push(() => { + mode.close(); + editor.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + }); + const scroll = vi + .spyOn(Element.prototype, "scrollIntoView") + .mockImplementation(() => {}); + vi.useFakeTimers(); + mode.open(); + return { editor, mode, scroll }; +} + +it("scrolls to the first rendered change when comparing stored snapshots", async () => { + const { editor, mode, scroll } = setup(); + await mode.select({ type: "snapshot", id: "2" }, { compareTo: "1" }); + expect(editor.domElement!.querySelector("[data-user-ids]")).not.toBeNull(); + await vi.advanceTimersByTimeAsync(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(scroll).toHaveBeenCalledWith({ block: "center", behavior: "smooth" }); +}); + +it.each(["close", "select"] as const)( + "cancels a pending diff scroll on %s", + async (action) => { + const { mode, scroll } = setup(); + await mode.select({ type: "snapshot", id: "2" }, { compareTo: "1" }); + if (action === "close") { + mode.close(); + } else { + await mode.select({ type: "snapshot", id: "1" }); + } + scroll.mockClear(); + await vi.advanceTimersByTimeAsync(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(scroll).not.toHaveBeenCalled(); + }, +); + +it("honors disabling automatic diff scrolling", async () => { + const { mode, scroll } = setup(false); + await mode.select({ type: "snapshot", id: "2" }, { compareTo: "1" }); + await vi.advanceTimersByTimeAsync(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(scroll).not.toHaveBeenCalled(); +}); + +it("shows the preview loader immediately and clears it on success", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { editor, mode } = setup(true, () => promise); + const selection = mode.select({ type: "snapshot", id: "1" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + resolve(); + await selection; + expect(editor.domElement!.classList.contains("bn-loading")).toBe(false); +}); + +it("clears the preview loader even for a fast selection", async () => { + const { editor, mode } = setup(); + const selection = mode.select({ type: "snapshot", id: "1" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + await selection; + expect(editor.domElement!.classList.contains("bn-loading")).toBe(false); +}); + +it("keeps the loader visible across successive selections", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { editor, mode } = setup(true, () => promise); + const first = mode.select({ type: "snapshot", id: "1" }); + const second = mode.select({ type: "snapshot", id: "2" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + const third = mode.select({ type: "snapshot", id: "1" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + resolve(); + await Promise.all([first, second, third]); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(false); +}); + +it("clears the preview loader when closing history", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { editor, mode } = setup(true, () => promise); + const selection = mode.select({ type: "snapshot", id: "1" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + mode.close(); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(false); + resolve(); + await selection; +}); + +it("clears the preview loader when a snapshot cannot be loaded", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { editor, mode } = setup(true, () => promise); + const selection = mode.select({ type: "snapshot", id: "missing" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(true); + resolve(); + expect(await selection).toMatchObject({ status: "error" }); + expect(editor.domElement!.classList.contains("bn-loading")).toBe(false); +}); diff --git a/packages/core/src/extensions/Versioning/Versioning.node.test.ts b/packages/core/src/extensions/Versioning/Versioning.node.test.ts new file mode 100644 index 0000000000..838d63718c --- /dev/null +++ b/packages/core/src/extensions/Versioning/Versioning.node.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + createVersioningExtension, + type VersioningController, +} from "./Versioning.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; +import { success } from "./__test__/result.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +it("configures once per editor during construction", async () => { + const configure = vi.fn((editor: BlockNoteEditor) => { + expect(editor.getExtension(ReadOnlyExtension)).toBeDefined(); + expect(editor.document).toHaveLength(1); + expect( + editor.getExtension("versioning")?.store.state, + ).toEqual({ mode: "live" }); + return { + adapter: { + supportsComparison: true, + open() { + return { + current: { content: "frozen", capturedAt: 1 }, + show() {}, + close() {}, + }; + }, + }, + storage: { + async list() { + return success({ snapshots: [] }); + }, + async getContent(id: string) { + return success(id); + }, + }, + resolveUsers: async () => [], + }; + }); + const Versions = createVersioningExtension(configure); + const extension = Versions(); + const editors = [ + BlockNoteEditor.create({ extensions: [extension] }), + BlockNoteEditor.create({ extensions: [extension] }), + ]; + try { + expect(configure).toHaveBeenCalledTimes(editors.length); + for (const [index, editor] of editors.entries()) { + expect(configure).toHaveBeenNthCalledWith(index + 1, editor); + const mode = editor.getExtension(Versions)!; + mode satisfies VersioningController; + expect(mode.store.state).toEqual({ mode: "live" }); + mode.close(); + const users = mode.userStore; + expect(users).toBeDefined(); + expect(mode.userStore).toBe(users); + expect(mode.canCompare).toBe(true); + expect(mode.canCreate).toBe(false); + expect(mode.canRestore).toBe(false); + expect(mode.canRemove).toBe(false); + expect(mode.canRename).toBe(false); + expect(await mode.rename("missing")).toEqual({ status: "unavailable" }); + mode.open(); + expect(await mode.list()).toEqual({ status: "done" }); + mode.close(); + mode.open(); + mode.close(); + expect(configure).toHaveBeenCalledTimes(editors.length); + } + expect(editors[0].getExtension(Versions)!.userStore).not.toBe( + editors[1].getExtension(Versions)!.userStore, + ); + } finally { + for (const editor of editors) { + editor.getExtension(Versions)!.close(); + editor._tiptapEditor.destroy(); + } + } +}); + +it("does not lazily initialize a versioning extension registered after creation", () => { + const configure = vi.fn(() => { + throw new Error("Late registration must not configure versioning"); + }); + const Versions = createVersioningExtension(configure); + const editor = BlockNoteEditor.create(); + try { + editor.registerExtension(Versions()); + const mode = editor.getExtension(Versions)!; + expect(mode.store.state).toEqual({ mode: "live" }); + mode.close(); + expect(() => mode.userStore).toThrow( + "Versioning must be installed during editor construction", + ); + expect(() => mode.canCreate).toThrow( + "Versioning must be installed during editor construction", + ); + expect(configure).not.toHaveBeenCalled(); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +function setup() { + const show = vi.fn(); + const Versions = createVersioningExtension(() => ({ + adapter: { + supportsComparison: true, + open() { + return { + current: { content: "frozen", capturedAt: 1 }, + show, + close() {}, + }; + }, + }, + storage: { + async list() { + return success({ snapshots: [] }); + }, + async getContent(id: string) { + return success(id); + }, + }, + })); + const editor = BlockNoteEditor.create({ extensions: [Versions()] }); + const mode = editor.getExtension(Versions)!; + vi.useFakeTimers(); + mode.open(); + return { editor, mode, show }; +} + +it.each(["close", "select"] as const)( + "clears the owned scroll timer on %s", + async (action) => { + const { editor, mode } = setup(); + try { + await mode.select({ type: "current" }, { compareTo: "old" }); + expect(vi.getTimerCount()).toBe(1); + if (action === "close") { + mode.close(); + } else { + await mode.select({ type: "current" }); + } + expect(vi.getTimerCount()).toBe(0); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } + }, +); + +it("clears an old view's scroll when a callback defers closing and reopening", async () => { + const { editor, mode, show } = setup(); + try { + show.mockImplementationOnce(() => { + queueMicrotask(() => { + mode.close(); + mode.open(); + }); + }); + await mode.select({ type: "current" }, { compareTo: "old" }); + expect(mode.store.state).toMatchObject({ + mode: "versions", + displayed: { type: "current" }, + }); + expect(mode.store.state).not.toHaveProperty("compareTo"); + expect(vi.getTimerCount()).toBe(0); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } +}); diff --git a/packages/core/src/extensions/Versioning/Versioning.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts deleted file mode 100644 index 158c152da4..0000000000 --- a/packages/core/src/extensions/Versioning/Versioning.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; - -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { UserStoreOrResolver } from "../../user/index.js"; -import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js"; -import type { VersionSnapshot } from "./Versioning.js"; -import { - createInMemoryPreviewController, - createInMemoryVersioningEndpoints, -} from "./inMemoryVersioning.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createEditor() { - const editor = BlockNoteEditor.create(); - const div = document.createElement("div"); - editor.mount(div); - return editor; -} - -function getEditorText(editor: BlockNoteEditor): string { - return editor.prosemirrorState.doc.textContent; -} - -function setEditorText(editor: BlockNoteEditor, text: string) { - editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]); -} - -/** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */ -function snap( - id: string, - createdAt: number, - extra?: Partial, -): VersionSnapshot { - return { id, createdAt, updatedAt: createdAt, ...extra }; -} - -/** - * Wire up a real editor with the in-memory versioning adapter. - * - * Returns the extension instance, the editor, and helpers to seed snapshots - * directly into the backend (bypassing the extension). - */ -function setup(opts?: { - initialText?: string; - withoutRestore?: boolean; - withoutUpdateName?: boolean; - resolveUsers?: UserStoreOrResolver; -}) { - const editor = createEditor(); - setEditorText(editor, opts?.initialText ?? "initial doc"); - - const endpoints = createInMemoryVersioningEndpoints(); - const preview = createInMemoryPreviewController(editor); - - if (opts?.withoutRestore) { - (endpoints as any).restore = undefined; - } - if (opts?.withoutUpdateName) { - (endpoints as any).rename = undefined; - } - - const ext = VersioningExtension({ - endpoints, - preview, - getCurrentDocument: () => editor.document, - resolveUsers: opts?.resolveUsers, - })({ editor }); - - /** Seed a snapshot into the backend by capturing the current editor doc. */ - const seed = async (text: string, name?: string) => { - // Temporarily set editor text, create via endpoints, then restore. - const savedBlocks = editor.document; - setEditorText(editor, text); - const blocks = editor.document; - const snapshot = await endpoints.create!(blocks, { name }); - // Restore original text. - editor.replaceBlocks(editor.document, savedBlocks); - // Refresh the store so the extension can resolve the seeded snapshot by id - // (preview/restore look snapshots up in the store, as the UI would after - // listing). - await ext.list(); - return snapshot; - }; - - return { ext, editor, endpoints, seed }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("sortSnapshotsNewestFirst", () => { - it("sorts newest-first by createdAt", () => { - const input = [snap("a", 100), snap("b", 300), snap("c", 200)]; - const sorted = sortSnapshotsNewestFirst(input); - expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]); - }); -}); - -describe("VersioningExtension", () => { - let ctx: ReturnType; - - beforeEach(() => { - ctx = setup(); - }); - - afterEach(() => { - ctx.editor.unmount(); - }); - - // ------------------------------------------------------------------------- - // Listing snapshots - // ------------------------------------------------------------------------- - - describe("listing snapshots", () => { - it("populates the store from the backend, sorted newest-first", async () => { - vi.useFakeTimers(); - - // Seed snapshots with distinct timestamps directly via endpoints. - await ctx.endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, - ]); - vi.advanceTimersByTime(1000); - await ctx.endpoints.create!([ - { - id: "2", - type: "paragraph" as const, - content: "v2" as any, - props: {} as any, - children: [], - }, - ]); - vi.advanceTimersByTime(1000); - await ctx.endpoints.create!([ - { - id: "3", - type: "paragraph" as const, - content: "v3" as any, - props: {} as any, - children: [], - }, - ]); - - const result = await ctx.ext.list(); - - expect(result).toHaveLength(3); - // Newest first: v3, v2, v1 - expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt); - expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt); - expect(ctx.ext.store.state.snapshots).toEqual(result); - - vi.useRealTimers(); - }); - - it("reflects backend changes on subsequent calls", async () => { - expect(await ctx.ext.list()).toEqual([]); - - await ctx.endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "external" as any, - props: {} as any, - children: [], - }, - ]); - - const after = await ctx.ext.list(); - expect(after).toHaveLength(1); - }); - }); - - // ------------------------------------------------------------------------- - // Creating snapshots - // ------------------------------------------------------------------------- - - describe("creating snapshots", () => { - it("captures the current state and adds the snapshot to the store", async () => { - setEditorText(ctx.editor, "my document content"); - - const snapshot = await ctx.ext.create!({ name: "Draft 1" }); - - expect(snapshot.name).toBe("Draft 1"); - expect(snapshot.id).toBeDefined(); - expect(ctx.ext.store.state.snapshots).toHaveLength(1); - - // The snapshot content should round-trip — verify by previewing. - await ctx.ext.previewSnapshot(snapshot.id); - expect(getEditorText(ctx.editor)).toBe("my document content"); - }); - - it("maintains newest-first order when adding to existing snapshots", async () => { - vi.useFakeTimers(); - - // Seed an older snapshot. - const old = await ctx.seed("old content", "Old"); - vi.advanceTimersByTime(1000); - - // List so the store knows about the seeded snapshot. - await ctx.ext.list(); - - const newer = await ctx.ext.create!({ name: "Newer" }); - - expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id); - expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id); - - vi.useRealTimers(); - }); - }); - - // ------------------------------------------------------------------------- - // Previewing snapshots - // ------------------------------------------------------------------------- - - describe("previewing snapshots", () => { - it("shows a snapshot and tracks it in the store", async () => { - const snap = await ctx.seed("snapshot content"); - - await ctx.ext.previewSnapshot(snap.id); - - expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id); - expect(getEditorText(ctx.editor)).toBe("snapshot content"); - }); - - it("supports comparing against an older snapshot", async () => { - const _v1 = await ctx.seed("content v1"); - const v2 = await ctx.seed("content v2"); - - // The in-memory preview controller doesn't render diffs, but the call - // should succeed and show the primary snapshot content. - await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id }); - - expect(getEditorText(ctx.editor)).toBe("content v2"); - }); - - it("switching previews updates to the new snapshot", async () => { - const s1 = await ctx.seed("content s1"); - const s2 = await ctx.seed("content s2"); - - await ctx.ext.previewSnapshot(s1.id); - expect(getEditorText(ctx.editor)).toBe("content s1"); - - await ctx.ext.previewSnapshot(s2.id); - expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id); - expect(getEditorText(ctx.editor)).toBe("content s2"); - }); - }); - - // ------------------------------------------------------------------------- - // Exiting preview - // ------------------------------------------------------------------------- - - describe("exiting preview", () => { - it("clears the preview state and restores the live document", async () => { - setEditorText(ctx.editor, "live content"); - const snap = await ctx.seed("snapshot content"); - - await ctx.ext.previewSnapshot(snap.id); - expect(getEditorText(ctx.editor)).toBe("snapshot content"); - - ctx.ext.exitPreview(); - - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); - expect(getEditorText(ctx.editor)).toBe("live content"); - }); - }); - - // ------------------------------------------------------------------------- - // Restoring snapshots - // ------------------------------------------------------------------------- - - describe("restoring snapshots", () => { - it("applies the snapshot content and exits any active preview", async () => { - setEditorText(ctx.editor, "current doc"); - const snap = await ctx.seed("old content"); - - // Enter preview first, then restore. - await ctx.ext.previewSnapshot(snap.id); - await ctx.ext.restore!(snap.id); - - expect(getEditorText(ctx.editor)).toBe("old content"); - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); - }); - - it("picks up server-side backup snapshots after re-listing", async () => { - const snap = await ctx.seed("original"); - await ctx.ext.list(); - - await ctx.ext.restore!(snap.id); - - // The in-memory endpoints create a backup snapshot on restore. - const updated = await ctx.ext.list(); - expect(updated.length).toBe(2); - expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe( - true, - ); - }); - - it("reports restore as unavailable when endpoint omits it", () => { - const noRestore = setup({ withoutRestore: true }); - expect(noRestore.ext.canRestore).toBe(false); - expect(noRestore.ext.restore).toBeUndefined(); - noRestore.editor.unmount(); - }); - }); - - // ------------------------------------------------------------------------- - // Updating snapshot names - // ------------------------------------------------------------------------- - - describe("updating snapshot names", () => { - it("renames a snapshot in the store and backend", async () => { - const snap = await ctx.seed("content", "Original"); - await ctx.ext.list(); - - await ctx.ext.rename!(snap.id, "Renamed"); - - // Store was updated optimistically. - expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed"); - - // Backend was also updated (verified via list). - const list = await ctx.ext.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed"); - }); - - it("reports name updates as unavailable when endpoint omits it", () => { - const noUpdate = setup({ withoutUpdateName: true }); - expect(noUpdate.ext.canRename).toBe(false); - expect(noUpdate.ext.rename).toBeUndefined(); - noUpdate.editor.unmount(); - }); - }); - - // ------------------------------------------------------------------------- - // User store (author resolution for `VersionSnapshot.by`) - // ------------------------------------------------------------------------- - - describe("user store", () => { - it("exposes an empty user store when no resolveUsers is provided", async () => { - expect(ctx.ext.userStore).toBeDefined(); - await ctx.ext.userStore.loadUsers(["u1"]); - expect(ctx.ext.userStore.getUser("u1")).toBeUndefined(); - }); - - it("builds a de-duped user store from a resolveUsers callback", async () => { - const resolveUsers = vi.fn(async (ids: string[]) => - ids.map((id) => ({ id, username: `name-${id}`, avatarUrl: "" })), - ); - const withUsers = setup({ resolveUsers }); - - await withUsers.ext.userStore.loadUsers(["u1", "u2"]); - expect(withUsers.ext.userStore.getUser("u1")?.username).toBe("name-u1"); - expect(withUsers.ext.userStore.getUser("u2")?.username).toBe("name-u2"); - - // Already-cached ids are not re-fetched. - await withUsers.ext.userStore.loadUsers(["u1"]); - expect(resolveUsers).toHaveBeenCalledTimes(1); - - withUsers.editor.unmount(); - }); - - it("passes `by` author ids through list() untouched", async () => { - const editor = createEditor(); - const ext = VersioningExtension({ - endpoints: { - list: async () => [snap("1", 100, { by: ["u1", "u2"] })], - getContent: async () => [], - }, - preview: createInMemoryPreviewController(editor), - getCurrentDocument: () => editor.document, - })({ editor }); - - const result = await ext.list(); - - // Raw ids are preserved — resolving them to user info is the view - // layer's job (via `ext.userStore`), never the extension's. - expect(result[0]!.by).toEqual(["u1", "u2"]); - expect(result[0]!.secondaryLabel).toBeUndefined(); - expect(ext.store.state.snapshots).toEqual(result); - - editor.unmount(); - }); - }); - - // ------------------------------------------------------------------------- - // End-to-end workflow - // ------------------------------------------------------------------------- - - describe("workflow: create, preview with diff, then restore", () => { - it("handles the full version-history flow", async () => { - vi.useFakeTimers(); - - // 1. Create version 1. - setEditorText(ctx.editor, "doc v1"); - const v1 = await ctx.ext.create!({ name: "Version 1" }); - - vi.advanceTimersByTime(1000); - - // 2. Modify and create version 2. - setEditorText(ctx.editor, "doc v2"); - const v2 = await ctx.ext.create!({ name: "Version 2" }); - expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id); - - // 3. Preview v1 with diff comparison against v2. - await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id }); - expect(getEditorText(ctx.editor)).toBe("doc v1"); - - // 4. Restore v1. - await ctx.ext.restore!(v1.id); - expect(getEditorText(ctx.editor)).toBe("doc v1"); - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); - - vi.useRealTimers(); - }); - }); -}); diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index cea8566ac5..12b18f34e5 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -1,628 +1,161 @@ +import { createExtension } from "../../editor/BlockNoteExtension.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - createExtension, - createStore, - type ExtensionOptions, -} from "../../editor/BlockNoteExtension.js"; +import type { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; +import { createVersioning } from "./createVersioning.js"; +import type { VersionStorage, VersionViewAdapter } from "./types.js"; +import { scheduleScrollToFirstChange } from "./scrollToFirstChange.js"; import { normalizeToUserStore, - type User, type UserStoreOrResolver, } from "../../user/index.js"; -/** - * Represents a single snapshot of a document's history, including metadata and content information. - * Snapshots are used for versioning and can be created, listed, restored, and previewed through the - * {@link VersioningEndpoints}. - */ -export interface VersionSnapshot { - /** - * The unique identifier for the snapshot. A plain string for real snapshots; - * the {@link CURRENT_VERSION_ID} symbol for the synthetic "Current version" - * entry (which no backend ever persists or round-trips). - */ - id: string | typeof CURRENT_VERSION_ID; - - /** - * The name of the snapshot. - */ - name?: string; - - /** - * The timestamp when the snapshot was created (unix timestamp). - */ - createdAt: number; - - /** - * The timestamp when the snapshot was last updated (unix timestamp). - */ - updatedAt: number; - - /** - * An optional secondary label for the snapshot, which can display additional information such as a custom description. - * This is for display purposes only and is not used for any logic in the versioning system. - * - * For author attribution, prefer {@link by}: it holds raw user ids that the - * view layer resolves to user info (and keeps up to date as users load). - * When both are set, `secondaryLabel` wins. - */ - secondaryLabel?: string; - - /** - * The id(s) of the user(s) that authored this version, as raw user ids — - * never pre-resolved to display names. The view layer resolves them via the - * {@link VersioningExtension}'s user store (see - * {@link VersioningExtensionOptions.resolveUsers}), reactively updating as - * user info loads. Only used when {@link secondaryLabel} is unset. - */ - by?: User["id"] | User["id"][]; - - /** - * The ID of the previous snapshot that this snapshot was restored from. - */ - restoredFromSnapshotId?: string; -} - -/** - * Identifier for a single {@link VersionSnapshot}, either the bare id or the - * whole reference. Tracks {@link VersionSnapshot.id}, so it also accepts the - * {@link CURRENT_VERSION_ID} symbol. - */ -export type VersionSnapshotIdentifier = - | VersionSnapshot["id"] - | Pick; - -/** - * The `id` of the synthetic "Current version" entry — the live document shown at - * the top of `list()` and set as `previewedSnapshotId` while previewing it (see - * {@link VersioningExtension.previewCurrentVersion}). - * - * A `unique symbol`, not a string, so it can never clash with a real snapshot id. - * It's client-only — never fetched via `getContent` / `getAttributions` (the row - * is previewed live) and never serialised, so no backend round-trips it. Because - * {@link VersionSnapshot.id} is `string | typeof CURRENT_VERSION_ID`, code that - * needs a string form for this one row (e.g. a React `key`) derives it locally. - */ -export const CURRENT_VERSION_ID: unique symbol = Symbol("bn-current-version"); - -/** - * The backend contract for versioning: **where snapshot data lives** (pure - * storage — in-memory, `localStorage`, HTTP, …). Counterpart to - * {@link PreviewController} (*how a snapshot is rendered*) and - * {@link VersioningExtensionOptions} (*how the live editor is bridged in*); - * {@link VersioningExtension} orchestrates the three. - * - * Type params trace the data flow: - * @typeParam Input - Live document handle passed to {@link create} / {@link restore}, - * from {@link VersioningExtensionOptions.getCurrentDocument} (e.g. `Y.Type`, `Block[]`). - * @typeParam Output - Serialised snapshot content from {@link getContent} / - * {@link restore}, rendered by {@link PreviewController.enterPreview} (e.g. `Uint8Array`). - * @typeParam Attributions - Optional diff-authorship data from {@link getAttributions}, - * also consumed by {@link PreviewController.enterPreview} (e.g. `Y.ContentMap`). - */ -export interface VersioningEndpoints< - Input = any, - Output = any, - Attributions = any, -> { - /** - * List all snapshots for this document, sorted newest-first by - * {@link VersionSnapshot.createdAt}. - */ - list: () => Promise; - /** - * Create a new snapshot from the current content. - * - * @note omit for backends with continuous history (e.g. YHub's activity - * timeline). Gates the extension's `canCreate` flag. - */ - create?: ( - /** Live document to snapshot, from {@link VersioningExtensionOptions.getCurrentDocument}. */ - content: Input, - options?: { - /** Optional name for this snapshot. */ - name?: string; - /** Id of the snapshot this one was restored from, if any. */ - restoredFromSnapshot?: VersionSnapshot; - }, - ) => Promise; - /** - * Restore the document to a snapshot. Implementations should create any backup - * snapshots they need before returning. - * - * @returns The restored content ({@link Output}, **not `void`**) — passed to - * {@link PreviewController.applyRestore}. - * @note omit to disable restore. Gates the extension's `canRestore` flag. - */ - restore?: ( - /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument} (for backup). */ - doc: Input, - /** The snapshot to restore. */ - snapshot: VersionSnapshot, - ) => Promise; - /** - * Fetch a snapshot's content ({@link Output}) for preview — same format as - * {@link VersioningExtensionOptions.serializeCurrentContent}. Sibling of - * {@link getAttributions}; both are the storage-side fetch that - * {@link PreviewController.enterPreview} renders. - */ - getContent: (snapshot: VersionSnapshot) => Promise; - /** - * Fetch diff-authorship data ({@link Attributions}: who/when) for the range - * `compareTo → snapshot`, rendered by {@link PreviewController.enterPreview} - * (its only consumer). Lives on the endpoint, not `enterPreview`, so one - * preview controller pairs with attribution-capable (YHub) or attribution-less - * (`localStorage`) backends — {@link Attributions} is that seam. - * - * @note omit and previews still render the content diff, minus attribution. - */ - getAttributions?: ( - /** The previewed snapshot (the "new" side of the diff). */ - snapshot: VersionSnapshot, - /** The baseline it's diffed against (the "old" side). */ - compareTo?: VersionSnapshot, - ) => Promise; - /** - * Rename a snapshot. - * - * @note omit to disable rename. Gates the extension's `canRename` flag. - */ - rename?: (snapshot: VersionSnapshot, name?: string) => Promise; - /** - * Permanently remove a snapshot. - * - * @note omit for immutable-history backends (e.g. YHub). Gates the extension's - * `canRemove` flag. - */ - remove?: (snapshot: VersionSnapshot) => Promise; -} - -/** - * A factory function for the endpoints to receive a reference to the editor. - * - * @typeParam Input - See {@link VersioningEndpoints}. - * @typeParam Output - See {@link VersioningEndpoints}. - * @typeParam Attributions - See {@link VersioningEndpoints}. - */ -export type VersioningEndpointsFactory< - Input = any, - Output = any, - Attributions = any, -> = ( - editor: BlockNoteEditor, -) => VersioningEndpoints; - -/** - * Controls **how a snapshot is rendered** — the render-side counterpart to - * {@link VersioningEndpoints} (storage). {@link VersioningExtension} fetches - * content/attributions from the endpoints and delegates rendering here; keeping - * the two separate lets one controller pair with different backends. - * - * @typeParam Output - Serialised snapshot content; matches the endpoints' `Output`. - * @typeParam Attributions - Optional attribution data; matches the endpoints' `Attributions`. - */ -export interface PreviewController { - /** - * Whether {@link enterPreview} can render a diff (uses `compareToContent`). - * Defaults to `true`; `false` for show-one-version-only backends (e.g. the Yjs - * v13 adapter). Surfaced as {@link VersioningExtension.canCompare}. - */ - supportsComparison?: boolean; - /** - * Enter preview mode. Arguments come from the endpoints: - * {@link VersioningEndpoints.getContent} (content) and - * {@link VersioningEndpoints.getAttributions} (attributions). - */ - enterPreview: ( - /** Snapshot to preview ({@link Output}, from {@link VersioningEndpoints.getContent}). */ - snapshotContent: Output, - /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */ - compareToContent?: Output, - /** - * Diff attributions ({@link Attributions}, from - * {@link VersioningEndpoints.getAttributions}). Only meaningful with - * `compareToContent`. - */ - attributions?: Attributions, - /** - * The snapshot(s) this preview is for (metadata only — the content is - * `snapshotContent` / `compareToContent`). Lets a controller label the - * preview with e.g. the version's name, without smuggling it through the - * {@link Attributions} channel. `snapshot` is the previewed version (the - * {@link CURRENT_VERSION_ID} entry when previewing the live document); - * `compareTo` is the baseline it's diffed against, if any. - */ - context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, - ) => void; - /** Exit preview mode and resume normal editing. */ - exitPreview: () => void; - /** - * Apply restored content to the live document. Called with the {@link Output} - * from {@link VersioningEndpoints.restore}, after preview mode has exited. - */ - applyRestore: (snapshotContent: Output) => void; -} - -/** Sort snapshots newest-first by creation time. */ -export function sortSnapshotsNewestFirst( - snapshots: VersionSnapshot[], -): VersionSnapshot[] { - return [...snapshots].sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * Options accepted by the {@link VersioningExtension} — **how the live editor is - * bridged in**, alongside the {@link VersioningEndpoints} (storage) and - * {@link PreviewController} (rendering). - * - * @typeParam Input - See {@link VersioningEndpoints}. - * @typeParam Output - See {@link VersioningEndpoints}. - * @typeParam Attributions - See {@link VersioningEndpoints}. - */ -export type VersioningExtensionOptions< - Input = any, - Output = any, - Attributions = any, -> = { - /** - * Backend storage for snapshots. - */ - endpoints: - | VersioningEndpoints - | VersioningEndpointsFactory; - /** - * Controls how snapshot previews and restores are rendered in the editor. - */ - preview: PreviewController; - /** - * The **live, mutable document handle** ({@link Input}) the backend snapshots - * *from* / restores *into*. Passed to {@link VersioningEndpoints.create} and - * {@link VersioningEndpoints.restore}. Cf. {@link serializeCurrentContent} (a - * detached copy); the two coincide for some backends (in-memory: - * `Input === Output === Block[]`) and differ for others (Yjs: `Y.Type` vs `Uint8Array`). - */ - getCurrentDocument: () => Input; - /** - * The live document **serialised to snapshot format** ({@link Output}, matching - * {@link VersioningEndpoints.getContent}), for diffing the live doc against a - * snapshot (see {@link VersioningExtension.previewCurrentVersion}). Cf. - * {@link getCurrentDocument} (the live handle). - * - * @note omit and the UI can't offer a "Current version" diff. Gates the - * extension's `canPreviewCurrent` flag. - */ - serializeCurrentContent?: () => Output | Promise; - /** - * Resolve user information for the author ids in {@link VersionSnapshot.by}, - * used by the view layer to render version-author labels. - * - * Either a resolver function (called with the ids of users that are not yet - * cached, returning their information — a user store is built from it - * internally) or a pre-built user store (see `createUserStore`). Pass the - * same store you give the comments/collaboration extensions so a single - * de-duped user cache is shared across features. - * - * @note omit and author ids are displayed as-is. - */ - resolveUsers?: UserStoreOrResolver; -}; - -function snapshotNotFoundError( - id: VersionSnapshotIdentifier | undefined, -): never { - const idResolved = typeof id === "object" ? id.id : id; - throw new Error(`Snapshot not found: ${String(idResolved)}`); -} - -export const VersioningExtension = createExtension( - ({ - options: optionsOrFactory, - editor, - }: ExtensionOptions< - | VersioningExtensionOptions - | ((editor: BlockNoteEditor) => VersioningExtensionOptions) - >) => { - const { - endpoints: endpointsRaw, - preview, - getCurrentDocument, - serializeCurrentContent, - resolveUsers, - } = typeof optionsOrFactory === "function" - ? optionsOrFactory(editor) - : optionsOrFactory; - - const endpoints = - typeof endpointsRaw === "function" ? endpointsRaw(editor) : endpointsRaw; - // With no resolver this is an empty store: `getUser` always misses, so the - // view layer falls back to showing the raw ids from `VersionSnapshot.by`. - const userStore = normalizeToUserStore(resolveUsers); - const store = createStore<{ - snapshots: VersionSnapshot[]; - /** - * The id of the version currently shown in the editor (the "new" side of - * a diff). `undefined` means the live, editable document. Is the - * {@link CURRENT_VERSION_ID} symbol when previewing the live document as a - * read-only diff against a snapshot. - */ - previewedSnapshotId?: string | typeof CURRENT_VERSION_ID; - /** - * The id of the snapshot the preview is being diffed against (the - * "baseline" / old side). `undefined` when not showing a diff. Always a - * real snapshot id (never the current entry), but typed as the same union - * as {@link VersionSnapshot.id} since it's copied from one. Used to render - * the "Comparing to" indicator in the sidebar. - */ - compareToSnapshotId?: string | typeof CURRENT_VERSION_ID; - }>({ - snapshots: [], - previewedSnapshotId: undefined, - compareToSnapshotId: undefined, +/** Configure storage and a view once; the sidebar only opens, selects, and closes. */ +export function createVersioningExtension< + Content, + Attributions = never, + BSchema extends BlockSchema = BlockSchema, + ISchema extends InlineContentSchema = InlineContentSchema, + SSchema extends StyleSchema = StyleSchema, +>( + configure: (editor: BlockNoteEditor) => { + adapter: VersionViewAdapter; + storage: VersionStorage; + resolveUsers?: UserStoreOrResolver; + scrollToFirstChange?: boolean; + }, +) { + type Editor = BlockNoteEditor; + return createExtension(({ editor }: { editor: Editor }) => { + let configuration: + | (ReturnType & { + userStore: ReturnType; + }) + | undefined; + + editor.on("create", () => { + const configured = configure(editor); + configuration = { + ...configured, + userStore: normalizeToUserStore(configured.resolveUsers), + }; }); - const getSnapshot = (id: VersionSnapshotIdentifier | undefined) => { - const idResolved = typeof id === "object" ? id.id : id; - return store.state.snapshots.find( - (snapshot) => snapshot.id === idResolved, - ); - }; - - const updateSnapshots = async () => { - const snapshots = sortSnapshotsNewestFirst(await endpoints.list()); - store.setState((state) => ({ - ...state, - snapshots, - })); - - return snapshots; - }; - - const previewSnapshot = async ( - id: VersionSnapshotIdentifier, - previewOptions?: { - /** - * When set, the preview shows a diff against this snapshot (typically the - * chronologically previous version in the history list). - */ - compareTo?: VersionSnapshotIdentifier; - }, - ) => { - const snapshot = getSnapshot(id); - - if (!snapshot) { - snapshotNotFoundError(id); - } - - const compareToSnapshot = previewOptions?.compareTo - ? getSnapshot(previewOptions.compareTo) - : undefined; - - store.setState((state) => ({ - ...state, - previewedSnapshotId: snapshot.id, - compareToSnapshotId: compareToSnapshot?.id, - })); - - let compareToContent: unknown; - let attributions: unknown; - if (compareToSnapshot) { - compareToContent = await endpoints.getContent(compareToSnapshot); - // Attributions describe the diff between the baseline and this - // snapshot, so they're only meaningful when comparing against another - // version. Fetching them is optional: previews still render the content - // diff without author/timestamp information when unavailable. - if (endpoints.getAttributions) { - attributions = await endpoints.getAttributions( - snapshot, - compareToSnapshot, - ); - } - } - - const snapshotContent = await endpoints.getContent(snapshot); - preview.enterPreview(snapshotContent, compareToContent, attributions, { - snapshot, - compareTo: compareToSnapshot, - }); - }; - - /** - * Preview the live ("current") document as a read-only diff against a - * snapshot baseline. Unlike {@link previewSnapshot}, the "new" side of the - * diff is the live document — serialised via `serializeCurrentContent` — - * rather than a stored snapshot. The editor becomes non-editable while - * previewing (editing is gated on `previewedSnapshotId === undefined`). - */ - const previewCurrentVersion = async (previewOptions?: { - /** - * The snapshot to diff the live document against (the baseline). When - * omitted, the live document is shown without a diff. - */ - compareTo?: VersionSnapshotIdentifier; - }) => { - if (!serializeCurrentContent) { + function getConfiguration() { + if (!configuration) { throw new Error( - "previewCurrentVersion requires `serializeCurrentContent` to be " + - "provided to the VersioningExtension options.", + "Versioning must be installed during editor construction", ); } + return configuration; + } - const compareToSnapshot = previewOptions?.compareTo - ? getSnapshot(previewOptions.compareTo) - : undefined; - - store.setState((state) => ({ - ...state, - previewedSnapshotId: CURRENT_VERSION_ID, - compareToSnapshotId: compareToSnapshot?.id, - })); - - // Synthesise a snapshot for the live document so timestamp-based backends - // (e.g. YHub) resolve the changeset window up to "now", and so the preview - // controller gets a snapshot to key off. The id is the current-version - // sentinel; backends ignore it and resolve the window from `createdAt`. - const currentSnapshot: VersionSnapshot = { - id: CURRENT_VERSION_ID, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - let compareToContent: unknown; - let attributions: unknown; - if (compareToSnapshot) { - compareToContent = await endpoints.getContent(compareToSnapshot); - if (endpoints.getAttributions) { - attributions = await endpoints.getAttributions( - currentSnapshot, - compareToSnapshot, - ); + const mode = createVersioning({ + get storage() { + return getConfiguration().storage; + }, + adapter: { + get supportsComparison() { + return getConfiguration().adapter.supportsComparison; + }, + open() { + const configured = getConfiguration(); + // Preview adapters replace the document. Keep undo detached through + // opening, switching and live restoration, then start fresh history. + const undoExtensions = editor.unregisterExtension([ + "history", + "yUndo", + ]); + let view: ReturnType; + try { + view = configured.adapter.open(); + } catch (error) { + editor.registerExtension(undoExtensions); + throw error; + } + let cancelScroll: (() => void) | undefined; + let closed = false; + return { + current: view.current, + show(display) { + cancelScroll?.(); + cancelScroll = undefined; + view.show(display); + if (display.comparison && !closed) { + cancelScroll = scheduleScrollToFirstChange( + () => editor.domElement, + { + enabled: configured.scrollToFirstChange, + isCurrent: () => + mode.store.state.mode === "versions" && + mode.store.state.pending === undefined && + !mode.store.state.restoring, + }, + ); + } + }, + close() { + if (closed) { + return; + } + closed = true; + cancelScroll?.(); + cancelScroll = undefined; + view.close(); + editor.registerExtension(undoExtensions); + }, + }; + }, + }, + setReadOnly(enabled) { + const readOnly = editor.getExtension(ReadOnlyExtension); + if (!readOnly) { + throw new Error("Versioning requires the ReadOnly extension"); } - } - - const currentContent = await serializeCurrentContent(); - preview.enterPreview(currentContent, compareToContent, attributions, { - snapshot: currentSnapshot, - compareTo: compareToSnapshot, - }); - }; - - const exitPreview = () => { - store.setState((state) => ({ - ...state, - previewedSnapshotId: undefined, - compareToSnapshotId: undefined, - })); - preview.exitPreview(); - }; - - return { - key: "versioning", - store, - userStore, - list: async (): Promise => { - return await updateSnapshots(); + readOnly.setReadOnly(enabled, "versioning"); }, - // Comparison is only offered when the preview controller can actually - // render a diff (see PreviewController.supportsComparison). A getter so a - // controller whose `supportsComparison` is itself dynamic (e.g. gated on - // an opt-in diff extension that may be registered after this one) is read - // lazily, not captured at init time. - get canCompare() { - return preview.supportsComparison !== false; + }); + const key = "versioning"; + return assignWithDescriptors(mode, { + key, + get userStore() { + return getConfiguration().userStore; }, - canCreate: endpoints.create !== undefined, - create: endpoints.create - ? async (options?: { - /** - * The optional name for this snapshot. - */ - name?: string; - /** - * The ID of the snapshot this one was restored from, if applicable. - */ - restoredFromSnapshot?: VersionSnapshotIdentifier; - }): Promise => { - const snapshot = await endpoints.create!(getCurrentDocument(), { - name: options?.name, - restoredFromSnapshot: getSnapshot(options?.restoredFromSnapshot), - }); - // Show the new version immediately. Some backends (e.g. YHub) build - // their version list from an activity timeline that lags a beat - // behind the create, so waiting on a re-list would leave the UI - // briefly stale. - store.setState((state) => ({ - ...state, - snapshots: sortSnapshotsNewestFirst([ - ...state.snapshots, - snapshot, - ]), - })); - // Reconcile with the backend's `list()` — it owns the "current - // version" entry and any server-assigned metadata. If the refreshed - // list doesn't include the just-created version yet (indexing lag), - // keep the optimistic entry so it never flickers out. - const listed = await endpoints.list(); - store.setState((state) => ({ - ...state, - snapshots: sortSnapshotsNewestFirst( - listed.some((s) => s.id === snapshot.id) - ? listed - : [...listed, snapshot], - ), - })); - return snapshot; - } - : undefined, - canRestore: endpoints.restore !== undefined, - restore: endpoints.restore - ? async (id: VersionSnapshotIdentifier) => { - exitPreview(); - const snapshot = getSnapshot(id); + mount() { + const unsubscribe = mode.store.subscribe(({ currentVal }) => { + editor.domElement?.classList.toggle( + "bn-loading", + currentVal.mode === "versions" && currentVal.pending !== undefined, + ); + }); - if (!snapshot) { - snapshotNotFoundError(id); - } - const snapshotContent = await endpoints.restore!( - getCurrentDocument(), - snapshot, - ); - preview.applyRestore(snapshotContent); - await updateSnapshots(); - return snapshotContent; - } - : undefined, - canRename: endpoints.rename !== undefined, - rename: endpoints.rename - ? async ( - id: VersionSnapshotIdentifier, - name?: string, - ): Promise => { - const snapshot = getSnapshot(id); - if (!snapshot) { - snapshotNotFoundError(id); - } - await endpoints.rename!(snapshot, name); - store.setState((state) => ({ - ...state, - snapshots: state.snapshots.map((s) => - s.id === id ? { ...s, name, updatedAt: Date.now() } : s, - ), - })); - } - : undefined, - canRemove: endpoints.remove !== undefined, - remove: endpoints.remove - ? async (id: VersionSnapshotIdentifier): Promise => { - const snapshot = getSnapshot(id); - if (!snapshot) { - snapshotNotFoundError(id); - } - // If the snapshot being removed is the one currently previewed, or - // the baseline it's being diffed against, exit preview first so the - // editor returns to the live document instead of showing (or - // comparing against) a version that no longer exists. - if ( - store.state.previewedSnapshotId === snapshot.id || - store.state.compareToSnapshotId === snapshot.id - ) { - exitPreview(); - } - await endpoints.remove!(snapshot); - // Remove it optimistically so the row disappears immediately, then - // reconcile with the backend's authoritative list. - store.setState((state) => ({ - ...state, - snapshots: state.snapshots.filter((s) => s.id !== snapshot.id), - })); - await updateSnapshots(); - } - : undefined, - previewSnapshot, - canPreviewCurrent: serializeCurrentContent !== undefined, - previewCurrentVersion: serializeCurrentContent - ? previewCurrentVersion - : undefined, - exitPreview, - } as const; - }, -); + return () => { + mode.close(); + unsubscribe(); + editor.domElement?.classList.remove("bn-loading"); + }; + }, + }); + }); +} + +/** Attach extension properties without evaluating their getters. */ +function assignWithDescriptors( + target: T, + source: U, +): T & U; +function assignWithDescriptors(target: object, source: object) { + return Object.defineProperties( + target, + Object.getOwnPropertyDescriptors(source), + ); +} + +/** Content-independent controls shared by every versioning integration. */ +export type VersioningController = ReturnType< + typeof createVersioning +> & { key: "versioning"; userStore: ReturnType }; diff --git a/packages/core/src/extensions/Versioning/__test__/result.ts b/packages/core/src/extensions/Versioning/__test__/result.ts new file mode 100644 index 0000000000..cc31f4ded5 --- /dev/null +++ b/packages/core/src/extensions/Versioning/__test__/result.ts @@ -0,0 +1,12 @@ +import type { VersionResult } from "../types.js"; + +export function success(value: T): VersionResult { + return { ok: true, value }; +} + +export function resultValue(result: VersionResult): T { + if (!result.ok) { + throw new Error(`Expected success, got ${result.error.type}`); + } + return result.value; +} diff --git a/packages/core/src/extensions/Versioning/comparison.test.ts b/packages/core/src/extensions/Versioning/comparison.test.ts new file mode 100644 index 0000000000..9a815e2b5a --- /dev/null +++ b/packages/core/src/extensions/Versioning/comparison.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node +import { expect, it, vi } from "vite-plus/test"; +import { createVersioning } from "./createVersioning.js"; +import { success } from "./__test__/result.js"; + +it("compares frozen current against stored content using the capture attribution cutoff", async () => { + const show = vi.fn(); + const getAttributions = vi.fn(async () => success(["author"])); + const mode = createVersioning({ + adapter: { + supportsComparison: true, + open: () => ({ + current: { content: "frozen", capturedAt: 123 }, + show, + close() {}, + }), + }, + storage: { + list: async () => success({ snapshots: [] }), + getContent: async (id) => success(id), + getAttributions, + }, + setReadOnly() {}, + }); + mode.open(); + await mode.select({ type: "current" }, { compareTo: "old" }); + expect(getAttributions).toHaveBeenCalledWith( + { type: "current" }, + "old", + 123, + expect.any(AbortSignal), + ); + expect(show).toHaveBeenCalledWith({ + content: "frozen", + comparison: { content: "old", attributions: ["author"] }, + target: { type: "current" }, + }); + expect(mode.store.state).toMatchObject({ compareTo: "old" }); + mode.dispose(); +}); diff --git a/packages/core/src/extensions/Versioning/createVersioning.test.ts b/packages/core/src/extensions/Versioning/createVersioning.test.ts new file mode 100644 index 0000000000..0f0ff217db --- /dev/null +++ b/packages/core/src/extensions/Versioning/createVersioning.test.ts @@ -0,0 +1,867 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vite-plus/test"; +import { createVersioning } from "./createVersioning.js"; +import { reduceVersioningState } from "./versioningState.js"; +import type { + VersionResult, + VersionSnapshotPage, + VersionStorage, + VersionViewAdapter, +} from "./types.js"; +import { success } from "./__test__/result.js"; + +describe("published state transitions", () => { + const opened = reduceVersioningState( + { mode: "live" }, + { + type: "opened", + capturedAt: 10, + showCurrentVersion: true, + restoring: false, + }, + ); + const loaded = reduceVersioningState(opened, { + type: "historyLoaded", + operation: "refresh", + snapshots: [{ id: "a", createdAt: 9 }], + nextCursor: "older", + }); + + it("retains rows and cursor while loading or failing, then clears the error on append", () => { + const pending = reduceVersioningState(loaded, { + type: "historyStarted", + operation: "loadMore", + }); + expect(pending).toMatchObject({ + nextCursor: "older", + history: { status: "pending", data: [{ id: "a" }] }, + }); + const failed = reduceVersioningState(pending, { + type: "historyFailed", + operation: "loadMore", + error: { type: "network" }, + }); + expect(failed).toMatchObject({ + nextCursor: "older", + history: { status: "error", data: [{ id: "a" }] }, + }); + const appended = reduceVersioningState(failed, { + type: "historyLoaded", + operation: "loadMore", + snapshots: [ + { id: "b", createdAt: 1 }, + { id: "a", createdAt: 9, name: "Updated" }, + ], + }); + expect(appended).toMatchObject({ + nextCursor: undefined, + history: { + status: "success", + data: [{ id: "a", name: "Updated" }, { id: "b" }], + }, + }); + if (appended.mode === "versions") { + expect(appended.history).not.toHaveProperty("error"); + expect(appended.history).not.toHaveProperty("operation"); + } + }); + + it("replaces history on refresh instead of keeping removed rows", () => { + expect( + reduceVersioningState(loaded, { + type: "historyLoaded", + operation: "refresh", + snapshots: [], + }), + ).toMatchObject({ + nextCursor: undefined, + history: { status: "success", data: [] }, + }); + }); + + it("keeps the displayed comparison when a new selection fails", () => { + const shown = reduceVersioningState(loaded, { + type: "selectionShown", + target: { type: "snapshot", id: "a" }, + compareTo: "b", + }); + const pending = reduceVersioningState(shown, { + type: "selectionStarted", + target: { type: "current" }, + }); + expect( + reduceVersioningState(pending, { type: "selectionFailed" }), + ).toMatchObject({ + displayed: { type: "snapshot", id: "a" }, + compareTo: "b", + pending: undefined, + }); + expect(shown).toMatchObject({ pending: undefined }); + }); + + it("publishes naming changes without mutating previous rows or dropping the cursor", () => { + const renamed = reduceVersioningState(loaded, { + type: "snapshotRenamed", + id: "a", + name: "Named", + }); + const created = reduceVersioningState(renamed, { + type: "snapshotCreated", + snapshot: { id: "new", createdAt: 10 }, + }); + expect(created).toMatchObject({ + nextCursor: "older", + history: { data: [{ id: "new" }, { id: "a", name: "Named" }] }, + }); + if (loaded.mode === "versions") { + expect(loaded.history.data).toEqual([{ id: "a", createdAt: 9 }]); + } + }); + + it("clears a pending selection when restore starts and ignores preview events after closing", () => { + const pending = reduceVersioningState(loaded, { + type: "selectionStarted", + target: { type: "current" }, + }); + expect( + reduceVersioningState(pending, { + type: "restoreChanged", + restoring: true, + }), + ).toMatchObject({ restoring: true, pending: undefined }); + const closed = reduceVersioningState(pending, { type: "closed" }); + expect( + reduceVersioningState(closed, { + type: "historyStarted", + operation: "refresh", + }), + ).toBe(closed); + }); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function setup(overrides: Partial> = {}) { + const show = vi.fn(); + const close = vi.fn(); + const open = vi.fn(() => ({ + current: { content: "frozen", capturedAt: 10 }, + show, + close, + })); + const adapter: VersionViewAdapter = { + supportsComparison: false, + open, + }; + const storage: VersionStorage = { + list: async () => success({ snapshots: [] }), + getContent: async (id) => success(id), + ...overrides, + }; + const setReadOnly = vi.fn(); + return { + mode: createVersioning({ adapter, storage, setReadOnly }), + show, + close, + open, + setReadOnly, + }; +} + +it.each([undefined, true, false])( + "opens history with showCurrentVersion=%s", + async (showCurrentVersion) => { + const latest = { id: "latest", createdAt: 9 }; + const getContent = vi.fn(async (id: string) => success(id)); + const { mode, show } = setup({ + showCurrentVersion, + list: async () => + success({ snapshots: [{ id: "old", createdAt: 1 }, latest] }), + getContent, + }); + mode.open(); + expect(await mode.list()).toEqual({ status: "done" }); + expect(mode.store.state).toMatchObject({ + displayed: + showCurrentVersion === false + ? { type: "snapshot", id: "latest" } + : { type: "current" }, + }); + if (showCurrentVersion === false) { + expect(getContent).toHaveBeenCalledWith( + "latest", + expect.any(AbortSignal), + ); + expect(show).toHaveBeenLastCalledWith({ + content: "latest", + target: { type: "snapshot", id: "latest" }, + }); + } else { + expect(getContent).not.toHaveBeenCalled(); + } + mode.close(); + }, +); + +it.each([undefined, false, true])( + "exposes beginning comparisons only when storage guarantees its first version, policy=%s", + (historyIncludesBeginning) => { + const { mode } = setup({ historyIncludesBeginning }); + expect(mode.historyIncludesBeginning).toBe( + historyIncludesBeginning === true, + ); + }, +); + +it("defers storage access and binds detached rename to the original storage", async () => { + const storage: VersionStorage = { + async list() { + return success({ snapshots: [] }); + }, + async getContent(id) { + return success(id); + }, + async rename() { + expect(this).toBe(storage); + return success(undefined); + }, + }; + const getStorage = vi.fn(() => storage); + const mode = createVersioning({ + adapter: { + supportsComparison: false, + open() { + return { + current: { content: "frozen", capturedAt: 1 }, + show() {}, + close() {}, + }; + }, + }, + get storage() { + return getStorage(); + }, + setReadOnly() {}, + }); + expect(getStorage).not.toHaveBeenCalled(); + expect(mode.canCreate).toBe(false); + expect(getStorage).toHaveBeenCalledTimes(1); + const rename = mode.rename; + await rename("old", "Named"); +}); + +it.each(["create", "rename"] as const)( + "publishes a successful %s locally before refresh and keeps success if refresh fails", + async (operation) => { + const original = { id: "old", createdAt: 5, name: "Original" }; + const refresh = deferred>(); + const list = vi + .fn() + .mockResolvedValueOnce(success({ snapshots: [original] })) + .mockReturnValueOnce(refresh.promise); + const { mode } = setup({ + list, + create: async () => success({ ...original, name: "Submitted" }), + rename: async () => success(undefined), + }); + mode.open(); + await mode.list(); + const saving = + operation === "create" + ? mode.create("Submitted") + : mode.rename("old", "Submitted"); + await vi.waitFor(() => + expect(mode.store.state).toMatchObject({ + history: { + status: "pending", + data: [{ ...original, name: "Submitted" }], + }, + }), + ); + refresh.resolve({ ok: false, error: { type: "network" } }); + expect(await saving).toEqual({ status: "done" }); + expect(mode.store.state).toMatchObject({ + history: { + status: "error", + error: { type: "network" }, + data: [{ ...original, name: "Submitted" }], + }, + }); + mode.close(); + }, +); + +it.each(["create", "rename"] as const)( + "keeps the stored name and skips refresh after a failed %s", + async (operation) => { + const original = { id: "old", createdAt: 5, name: "Original" }; + const list = vi.fn(async () => success({ snapshots: [original] })); + const { mode } = setup({ + list, + create: async () => ({ ok: false, error: { type: "conflict" } }), + rename: async () => ({ ok: false, error: { type: "conflict" } }), + }); + mode.open(); + await mode.list(); + const result = await (operation === "create" + ? mode.create("Submitted") + : mode.rename("old", "Submitted")); + expect(result).toEqual({ status: "error", error: { type: "conflict" } }); + expect(list).toHaveBeenCalledOnce(); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data: [original] }, + }); + mode.close(); + }, +); + +it("opens immediately without history, reuses its capture, and closes once", async () => { + const { mode, open, close, show, setReadOnly } = setup(); + mode.open(); + mode.open(); + expect(open).toHaveBeenCalledTimes(1); + expect(setReadOnly).toHaveBeenCalledWith(true); + expect(mode.store.state.mode).toBe("versions"); + await mode.select({ type: "snapshot", id: "old" }); + await mode.select({ type: "current" }); + expect(show.mock.lastCall?.[0].content).toBe("frozen"); + mode.close(); + mode.close(); + expect(close).toHaveBeenCalledTimes(1); + expect(setReadOnly).toHaveBeenLastCalledWith(false); +}); + +it("ignores endpoints that finish after abort, including across reopen", async () => { + const slow = deferred(); + const { mode, show } = setup({ + getContent: async () => success(await slow.promise), + }); + mode.open(); + const old = mode.select({ type: "snapshot", id: "slow" }); + await mode.select({ type: "current" }); + mode.close(); + mode.open(); + slow.resolve("stale"); + expect(await old).toEqual({ status: "cancelled" }); + expect(show).toHaveBeenCalledTimes(1); + expect(mode.store.state).toMatchObject({ + mode: "versions", + displayed: { type: "current" }, + }); +}); + +it("a failed fetch retains the displayed selection and version mode", async () => { + const { mode, close } = setup({ + getContent: async () => { + throw new Error("network"); + }, + }); + mode.open(); + await expect( + mode.select({ type: "snapshot", id: "missing" }), + ).rejects.toThrow("network"); + expect(mode.store.state).toMatchObject({ + mode: "versions", + displayed: { type: "current" }, + pending: undefined, + }); + expect(close).not.toHaveBeenCalled(); +}); + +it("stale history cannot publish into a later opening", async () => { + const pending = deferred<[]>(); + const { mode } = setup({ + list: async () => success({ snapshots: await pending.promise }), + }); + mode.open(); + const list = mode.list(); + mode.close(); + mode.open(); + pending.resolve([]); + expect(await list).toEqual({ status: "cancelled" }); + expect(mode.store.state).toMatchObject({ history: { status: "pending" } }); +}); + +it("restores live content once, blocks selection, then closes version mode", async () => { + const pending = deferred(); + const restore = vi.fn(async () => success(await pending.promise)); + const { mode, close, setReadOnly } = setup({ restore }); + mode.open(); + const result = mode.restore("old"); + expect(await mode.restore("another")).toEqual({ status: "unavailable" }); + expect(await mode.select({ type: "current" })).toEqual({ + status: "unavailable", + }); + pending.resolve(); + expect(await result).toEqual({ status: "done" }); + expect(restore).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(mode.store.state.mode).toBe("live"); + expect(setReadOnly).toHaveBeenLastCalledWith(false); +}); + +it("blocks opening during restore and keeps editing restricted until it completes", async () => { + const pending = deferred>(); + const { mode, open, setReadOnly } = setup({ + restore: () => pending.promise, + }); + mode.open(); + const restoring = mode.restore("old"); + mode.close(); + expect(mode.open()).toBe(false); + expect(open).toHaveBeenCalledOnce(); + expect(mode.store.state).toEqual({ mode: "live", restoring: true }); + expect(setReadOnly).toHaveBeenLastCalledWith(true); + pending.resolve(success(undefined)); + expect(await restoring).toEqual({ status: "done" }); + expect(setReadOnly).toHaveBeenLastCalledWith(false); + expect(mode.open()).toBe(true); + expect(open).toHaveBeenCalledTimes(2); +}); + +it("releases the restore lock if publishing busy state throws", async () => { + const restore = vi.fn(async () => success(undefined)); + const { mode } = setup({ restore }); + const cause = new Error("subscriber failed"); + mode.open(); + const unsubscribe = mode.store.subscribe(({ currentVal }) => { + if (currentVal.mode === "versions" && currentVal.restoring) { + unsubscribe(); + throw cause; + } + }); + await expect(mode.restore("old")).rejects.toBe(cause); + expect(mode.store.state).toMatchObject({ restoring: false }); + expect(restore).not.toHaveBeenCalled(); + expect(await mode.restore("old")).toEqual({ status: "done" }); + expect(restore).toHaveBeenCalledTimes(1); + expect(mode.store.state).toEqual({ mode: "live" }); +}); + +it("creates from frozen current even while displaying history", async () => { + const create = vi.fn(async () => success({ id: "new", createdAt: 11 })); + const { mode } = setup({ create }); + mode.open(); + await mode.select({ type: "snapshot", id: "old" }); + await mode.create("Named"); + expect(create).toHaveBeenCalledWith("frozen", "Named", 10); +}); + +it("keeps a selected checkpoint when removal only clears its name", async () => { + const { mode, show } = setup({ + remove: async () => success(undefined), + list: async () => success({ snapshots: [{ id: "old", createdAt: 1 }] }), + }); + mode.open(); + await mode.select({ type: "snapshot", id: "old" }); + show.mockClear(); + expect(await mode.remove("old")).toEqual({ status: "done" }); + expect(show).not.toHaveBeenCalled(); + expect(mode.store.state).toMatchObject({ + displayed: { type: "snapshot", id: "old" }, + }); +}); + +it("returns to frozen current if removal deletes the selected content", async () => { + let deleted = false; + const { mode, show, close } = setup({ + remove: async () => { + deleted = true; + return success(undefined); + }, + getContent: async (id) => + deleted ? { ok: false, error: { type: "not-found" } } : success(id), + }); + mode.open(); + await mode.select({ type: "snapshot", id: "old" }); + expect(await mode.remove("old")).toEqual({ status: "done" }); + expect(show.mock.lastCall?.[0].content).toBe("frozen"); + expect(close).not.toHaveBeenCalled(); +}); + +it("retains loaded history on an expected refresh failure and clears it on retry", async () => { + const data = [{ id: "old", createdAt: 1 }]; + const list = vi + .fn["list"]>() + .mockResolvedValueOnce(success({ snapshots: data })) + .mockResolvedValueOnce({ ok: false, error: { type: "network" } }) + .mockResolvedValueOnce(success({ snapshots: data })); + const { mode } = setup({ list }); + mode.open(); + await mode.list(); + expect(await mode.list()).toEqual({ + status: "error", + error: { type: "network" }, + }); + expect(mode.store.state).toMatchObject({ + history: { status: "error", data, error: { type: "network" } }, + }); + await mode.list(); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data }, + }); +}); + +it("keeps the previous preview on an expected selection failure and permits retry", async () => { + const getContent = vi + .fn["getContent"]>() + .mockResolvedValueOnce({ ok: false, error: { type: "not-found" } }) + .mockResolvedValueOnce(success("old")); + const { mode, show } = setup({ getContent }); + mode.open(); + expect(await mode.select({ type: "snapshot", id: "old" })).toEqual({ + status: "error", + error: { type: "not-found" }, + }); + expect(show).not.toHaveBeenCalled(); + expect(mode.store.state).toMatchObject({ + displayed: { type: "current" }, + pending: undefined, + }); + expect(await mode.select({ type: "snapshot", id: "old" })).toEqual({ + status: "done", + }); +}); + +it.each([true, false])( + "reconciles a pending comparison only when removal deletes its baseline (%s)", + async (deletesContent) => { + const baseline = deferred>(); + let removed = false; + const show = vi.fn(); + const mode = createVersioning({ + adapter: { + supportsComparison: true, + open: () => ({ + current: { content: "frozen", capturedAt: 10 }, + show, + close: vi.fn(), + }), + }, + storage: { + list: async () => success({ snapshots: [] }), + getContent: async (id) => { + if (id !== "baseline") { + return success(id); + } + if (!removed) { + return baseline.promise; + } + return deletesContent + ? { ok: false, error: { type: "not-found" } } + : success("baseline"); + }, + remove: async () => { + removed = true; + return success(undefined); + }, + }, + setReadOnly: vi.fn(), + }); + mode.open(); + const selection = mode.select( + { type: "snapshot", id: "target" }, + { compareTo: "baseline" }, + ); + expect(await mode.remove("baseline")).toEqual({ status: "done" }); + baseline.resolve(success("baseline")); + expect(await selection).toEqual({ + status: deletesContent ? "cancelled" : "done", + }); + expect(show).toHaveBeenLastCalledWith( + deletesContent + ? { content: "frozen", target: { type: "current" } } + : { + content: "target", + target: { type: "snapshot", id: "target" }, + comparison: { content: "baseline", attributions: undefined }, + }, + ); + mode.dispose(); + }, +); + +it("does not refresh history or change the preview after failed removal", async () => { + const list = vi.fn(async () => success({ snapshots: [] })); + const { mode, show } = setup({ + list, + remove: async () => ({ ok: false, error: { type: "forbidden" } }), + }); + mode.open(); + await mode.select({ type: "snapshot", id: "old" }); + show.mockClear(); + expect(await mode.remove("old")).toEqual({ + status: "error", + error: { type: "forbidden" }, + }); + expect(list).not.toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); +}); + +it.each(["success", "error"] as const)( + "can reopen after a closed restore finishes with %s", + async (outcome) => { + const pending = deferred>(); + const { mode, open, setReadOnly } = setup({ + restore: () => pending.promise, + }); + mode.open(); + const restore = mode.restore("old"); + mode.close(); + expect(setReadOnly).toHaveBeenLastCalledWith(true); + expect(mode.open()).toBe(false); + expect(open).toHaveBeenCalledOnce(); + expect(mode.store.state).toEqual({ mode: "live", restoring: true }); + expect(await mode.list()).toEqual({ status: "unavailable" }); + pending.resolve( + outcome === "success" + ? success(undefined) + : { ok: false, error: { type: "timeout", outcome: "unknown" } }, + ); + await restore; + expect(setReadOnly).toHaveBeenLastCalledWith(false); + expect(mode.open()).toBe(true); + await mode.list(); + expect(mode.store.state).toMatchObject({ + mode: "versions", + displayed: { type: "current" }, + restoring: false, + history: { status: "success" }, + }); + expect(open).toHaveBeenCalledTimes(2); + mode.close(); + expect(setReadOnly).toHaveBeenLastCalledWith(false); + }, +); + +describe("pagination", () => { + const newest = { id: "newest", createdAt: 100 }; + const middle = { id: "middle", createdAt: 80 }; + const oldest = { id: "oldest", createdAt: 60 }; + + it("appends deduplicated metadata without changing the displayed version and stops at exhaustion", async () => { + const list = vi + .fn["list"]>() + .mockResolvedValueOnce( + success({ snapshots: [newest, middle], nextCursor: "older" }), + ) + .mockResolvedValueOnce( + success({ snapshots: [{ ...middle, name: "Updated" }, oldest] }), + ); + const { mode, show } = setup({ list }); + mode.open(); + await mode.list(); + await mode.select({ type: "snapshot", id: middle.id }); + await mode.loadMore(); + expect(mode.store.state).toMatchObject({ + displayed: { type: "snapshot", id: middle.id }, + history: { + status: "success", + data: [newest, { ...middle, name: "Updated" }, oldest], + }, + nextCursor: undefined, + }); + expect(show.mock.lastCall?.[0].content).toBe(middle.id); + expect(await mode.loadMore()).toEqual({ status: "unavailable" }); + expect(list).toHaveBeenCalledTimes(2); + }); + + it("shares concurrent loads, retains history on failure, and loads again only on request", async () => { + const page = deferred>(); + const list = vi + .fn["list"]>() + .mockResolvedValueOnce( + success({ snapshots: [newest], nextCursor: "older" }), + ) + .mockReturnValueOnce(page.promise) + .mockResolvedValueOnce(success({ snapshots: [middle] })); + const { mode } = setup({ list }); + mode.open(); + await mode.list(); + const first = mode.loadMore(); + expect(mode.loadMore()).toBe(first); + expect(mode.store.state).toMatchObject({ + history: { status: "pending", data: [newest] }, + }); + page.resolve({ ok: false, error: { type: "network" } }); + expect(await first).toEqual({ + status: "error", + error: { type: "network" }, + }); + expect(mode.store.state).toMatchObject({ + history: { status: "error", data: [newest] }, + nextCursor: "older", + }); + expect(list).toHaveBeenCalledTimes(2); + await mode.loadMore(); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data: [newest, middle] }, + }); + }); + + it.each(["refresh", "reopen"] as const)( + "ignores an older page after %s", + async (operation) => { + const page = deferred>(); + const fresh = { ...newest, name: "Fresh" }; + const list = vi + .fn["list"]>() + .mockResolvedValueOnce( + success({ snapshots: [newest], nextCursor: "older" }), + ) + .mockReturnValueOnce(page.promise) + .mockResolvedValueOnce(success({ snapshots: [fresh] })); + const { mode } = setup({ list }); + mode.open(); + await mode.list(); + const pending = mode.loadMore(); + if (operation === "reopen") { + mode.close(); + mode.open(); + } + await mode.list(); + page.resolve(success({ snapshots: [middle] })); + expect(await pending).toEqual({ status: "cancelled" }); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data: [fresh] }, + nextCursor: undefined, + }); + }, + ); + + it("resets pagination on refresh without losing an older selected version", async () => { + const tied = { ...middle, id: "tied" }; + const list = vi + .fn["list"]>() + .mockResolvedValueOnce( + success({ snapshots: [newest, middle, tied], nextCursor: "older" }), + ) + .mockResolvedValueOnce( + success({ snapshots: [newest], nextCursor: "older" }), + ) + .mockResolvedValue(success({ snapshots: [middle, tied, oldest] })); + const { mode, show } = setup({ list }); + mode.open(); + await mode.list(); + await mode.select({ type: "snapshot", id: tied.id }); + await mode.list(); + expect(mode.store.state).toMatchObject({ + history: { data: [newest] }, + nextCursor: "older", + displayed: { type: "snapshot", id: tied.id }, + }); + expect(show.mock.lastCall?.[0].content).toBe(tied.id); + expect(list).toHaveBeenCalledTimes(2); + }); + + it("keeps loaded rows on refresh failure, then resets to page one on manual retry", async () => { + const fresh = { ...newest, name: "Fresh" }; + const list = vi + .fn["list"]>() + .mockResolvedValueOnce( + success({ snapshots: [newest], nextCursor: "middle" }), + ) + .mockResolvedValueOnce( + success({ snapshots: [middle], nextCursor: "oldest" }), + ) + .mockResolvedValueOnce({ ok: false, error: { type: "network" } }) + .mockResolvedValueOnce( + success({ snapshots: [fresh], nextCursor: "middle" }), + ) + .mockResolvedValueOnce(success({ snapshots: [middle, oldest] })); + const { mode } = setup({ list }); + mode.open(); + await mode.list(); + await mode.loadMore(); + await mode.list(); + expect(mode.store.state).toMatchObject({ + history: { status: "error", data: [newest, middle] }, + nextCursor: "oldest", + }); + await mode.loadMore(); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data: [fresh] }, + nextCursor: "middle", + }); + }); + + it("resets pagination after naming and creation and verifies deleted selected content", async () => { + let snapshots = [newest, middle, oldest]; + const created = { id: "created", createdAt: 110 }; + const { mode, show } = setup({ + list: async (_signal, cursor) => { + const offset = Number(cursor ?? 0); + return success({ + snapshots: snapshots.slice(offset, offset + 2), + nextCursor: + offset + 2 < snapshots.length ? String(offset + 2) : undefined, + }); + }, + rename: async (id, name) => { + snapshots = snapshots.map((snapshot) => + snapshot.id === id ? { ...snapshot, name } : snapshot, + ); + return success(undefined); + }, + create: async () => { + snapshots = [created, ...snapshots]; + return success(created); + }, + remove: async (id) => { + snapshots = snapshots.filter((snapshot) => snapshot.id !== id); + return success(undefined); + }, + getContent: async (id) => + snapshots.some((snapshot) => snapshot.id === id) + ? success(id) + : { ok: false, error: { type: "not-found" } }, + }); + mode.open(); + await mode.list(); + await mode.loadMore(); + await mode.rename(oldest.id, "Old draft"); + await mode.create(); + expect(mode.store.state).toMatchObject({ + history: { + data: [created, newest], + }, + }); + await mode.select({ type: "snapshot", id: middle.id }); + await mode.remove(middle.id); + expect(mode.store.state).toMatchObject({ + displayed: { type: "current" }, + history: { data: [created, newest] }, + }); + expect(show.mock.lastCall?.[0].content).toBe("frozen"); + }); + + it("allows a manual load after the initial page fails", async () => { + const list = vi + .fn["list"]>() + .mockResolvedValueOnce({ ok: false, error: { type: "network" } }) + .mockResolvedValueOnce(success({ snapshots: [newest] })); + const { mode } = setup({ list }); + mode.open(); + await mode.list(); + await mode.loadMore(); + expect(mode.store.state).toMatchObject({ + history: { status: "success", data: [newest] }, + }); + }); + + it("rejects a non-advancing continuation instead of appending forever", async () => { + const { mode } = setup({ + list: async () => success({ snapshots: [newest], nextCursor: "same" }), + }); + mode.open(); + await mode.list(); + await expect(mode.loadMore()).rejects.toThrow("cursor did not advance"); + expect(await mode.list()).toEqual({ status: "done" }); + }); +}); diff --git a/packages/core/src/extensions/Versioning/createVersioning.ts b/packages/core/src/extensions/Versioning/createVersioning.ts new file mode 100644 index 0000000000..037d5b1b58 --- /dev/null +++ b/packages/core/src/extensions/Versioning/createVersioning.ts @@ -0,0 +1,499 @@ +import { Store } from "../../util/Store.js"; +import { + reduceVersioningState, + type VersioningEvent, +} from "./versioningState.js"; +import type { + VersioningState, + VersionDisplay, + VersionResult, + VersionOperationResult, + VersionSelection, + VersionStorage, + VersionView, + VersionViewAdapter, +} from "./types.js"; + +/** One replaceable read per slot. The session lifetime cancels both slots. */ +function createRequestSlot(lifetime: AbortSignal) { + let current: AbortController | undefined; + function cancel() { + current?.abort(); + } + function start() { + const previous = current; + const controller = new AbortController(); + current = controller; + const signal = AbortSignal.any([lifetime, controller.signal]); + // Install the replacement before notifying the cancelled request. + previous?.abort(); + return { + signal, + cancel() { + controller.abort(); + }, + }; + } + return { start, cancel }; +} + +/** + * One isolated view per opening. Every read belongs to that opening. + * Adapter/provider callbacks and store subscribers must defer controller commands; + * synchronous reentry and recovery after unexpected callback failures are unsupported. + */ +export function createVersioning(options: { + adapter: VersionViewAdapter; + storage: VersionStorage; + setReadOnly: (enabled: boolean) => void; +}) { + const { adapter, setReadOnly } = options; + const store = new Store({ mode: "live" }); + type Session = { + view: VersionView; + lifetime: AbortController; + selection: ReturnType; + selectionBaseline?: string; + history: ReturnType; + loadingMore?: Promise; + }; + let session: Session | undefined; + let disposed = false; + let restoring = false; + + function dispatch(event: VersioningEvent) { + const previous = store.state; + const next = reduceVersioningState(previous, event); + if (next !== previous) { + store.setState(next); + } + return next; + } + + function publish(signal: AbortSignal, event: VersioningEvent) { + if (!signal.aborted) { + return dispatch(event); + } + return undefined; + } + + function open() { + if (disposed) { + throw new Error("Versioning has been disposed"); + } + if (restoring) { + return false; + } + if (session) { + return true; + } + try { + setReadOnly(true); + const view = adapter.open(); + const lifetime = new AbortController(); + session = { + view, + lifetime, + selection: createRequestSlot(lifetime.signal), + history: createRequestSlot(lifetime.signal), + }; + dispatch({ + type: "opened", + capturedAt: session.view.current.capturedAt, + showCurrentVersion: options.storage.showCurrentVersion ?? true, + restoring, + }); + } catch (error) { + if (!session) { + setReadOnly(restoring); + } + throw error; + } + return true; + } + + function close() { + if (!session) { + return; + } + const closing = session; + session = undefined; + closing.lifetime.abort(); + closing.view.close(); + dispatch({ type: "closed", restoring }); + setReadOnly(restoring); + } + + /** Reset history to the first page without changing the displayed preview. */ + function list(): Promise { + const active = session; + if (!active || restoring) { + return Promise.resolve({ status: "unavailable" }); + } + active.loadingMore = undefined; + return readHistory(active, { operation: "refresh" }); + } + + /** Append one page. Concurrent callers share the same request. */ + function loadMore(): Promise { + const active = session; + const state = store.state; + if (!active || restoring || state.mode !== "versions") { + return Promise.resolve({ status: "unavailable" }); + } + if (active.loadingMore) { + return active.loadingMore; + } + if ( + state.history.status === "error" && + state.history.operation === "refresh" + ) { + return list(); + } + if (state.nextCursor === undefined || state.history.status === "pending") { + return Promise.resolve({ status: "unavailable" }); + } + return readHistory(active, { + operation: "loadMore", + cursor: state.nextCursor, + }); + } + + function readHistory( + active: Session, + read: { operation: "refresh" } | { operation: "loadMore"; cursor: string }, + ): Promise { + const { signal } = active.history.start(); + publish(signal, { type: "historyStarted", operation: read.operation }); + const pending = (async (): Promise => { + try { + const result = await options.storage.list( + signal, + read.operation === "refresh" ? undefined : read.cursor, + ); + if (signal.aborted) { + return { status: "cancelled" }; + } + if (!result.ok) { + publish(signal, { + type: "historyFailed", + operation: read.operation, + error: result.error, + }); + return { status: "error", error: result.error }; + } + if ( + read.operation === "loadMore" && + result.value.nextCursor === read.cursor + ) { + throw new Error("Version history cursor did not advance"); + } + const loaded = publish(signal, { + type: "historyLoaded", + operation: read.operation, + ...result.value, + }); + if (signal.aborted) { + return { status: "cancelled" }; + } + if ( + loaded?.mode === "versions" && + loaded.showCurrentVersion === false && + loaded.displayed.type === "current" && + !loaded.pending && + loaded.history.data?.[0] + ) { + return select({ type: "snapshot", id: loaded.history.data[0].id }); + } + return { status: "done" }; + } catch (error) { + if (signal.aborted) { + return { status: "cancelled" }; + } + throw error; + } finally { + if (!signal.aborted) { + active.loadingMore = undefined; + } + } + })(); + if (read.operation === "loadMore" && !signal.aborted) { + active.loadingMore = pending; + } + return pending; + } + + async function loadDisplay( + active: Session, + target: VersionSelection, + baselineId: string | undefined, + signal: AbortSignal, + ): Promise>> { + const storage = options.storage; + const [content, comparison] = await Promise.all([ + target.type === "current" + ? { ok: true as const, value: active.view.current.content } + : storage.getContent(target.id, signal), + baselineId === undefined + ? undefined + : Promise.all([ + storage.getContent(baselineId, signal), + storage.getAttributions?.( + target, + baselineId, + active.view.current.capturedAt, + signal, + ), + ]), + ]); + if (!content.ok) { + return content; + } + if (!comparison) { + return { ok: true, value: { content: content.value, target } }; + } + const [baseline, attributions] = comparison; + if (!baseline.ok) { + return baseline; + } + if (attributions && !attributions.ok) { + return attributions; + } + return { + ok: true, + value: { + content: content.value, + target, + comparison: { + content: baseline.value, + attributions: attributions?.value, + }, + }, + }; + } + + async function select( + target: VersionSelection, + selectionOptions?: { compareTo?: string }, + ): Promise { + const active = session; + if (!active || restoring) { + return { status: "unavailable" }; + } + if ( + target.type === "current" && + options.storage.showCurrentVersion === false + ) { + const state = store.state; + const latest = + state.mode === "versions" ? state.history.data?.[0] : undefined; + if (!latest) { + return { status: "unavailable" }; + } + target = { type: "snapshot", id: latest.id }; + } + if ( + selectionOptions?.compareTo !== undefined && + !adapter.supportsComparison + ) { + return { status: "unavailable" }; + } + const request = active.selection.start(); + const { signal } = request; + active.selectionBaseline = selectionOptions?.compareTo; + publish(signal, { type: "selectionStarted", target }); + const baselineId = selectionOptions?.compareTo; + try { + const result = await loadDisplay(active, target, baselineId, signal); + if (signal.aborted) { + return { status: "cancelled" }; + } + if (!result.ok) { + publish(signal, { type: "selectionFailed" }); + return { status: "error", error: result.error }; + } + active.view.show(result.value); + publish(signal, { + type: "selectionShown", + target, + compareTo: baselineId, + }); + return { status: "done" }; + } catch (error) { + if (signal.aborted) { + return { status: "cancelled" }; + } + publish(signal, { type: "selectionFailed" }); + request.cancel(); + throw error; + } + } + + async function restore(id: string): Promise { + const active = session; + if (!active || restoring) { + return { status: "unavailable" }; + } + const storage = options.storage; + if (!storage.restore) { + return { status: "unavailable" }; + } + restoring = true; + try { + active.selection.cancel(); + publish(active.lifetime.signal, { + type: "restoreChanged", + restoring: true, + }); + const result = await storage.restore(id); + if (!result.ok) { + return { status: "error", error: result.error }; + } + close(); + return { status: "done" }; + } finally { + restoring = false; + dispatch({ type: "restoreChanged", restoring: false }); + if (!session) { + setReadOnly(false); + } + } + } + + async function refreshAfterNaming( + active: Session, + event: Extract< + VersioningEvent, + { type: "snapshotRenamed" | "snapshotCreated" } + >, + ) { + publish(active.lifetime.signal, event); + if (!active.lifetime.signal.aborted) { + await list(); + } + } + + async function rename( + id: string, + name?: string, + ): Promise { + const active = session; + const storage = options.storage; + if (restoring || !storage.rename) { + return { status: "unavailable" }; + } + const result = await storage.rename(id, name); + if (!result.ok) { + return { status: "error", error: result.error }; + } + if (active) { + await refreshAfterNaming(active, { type: "snapshotRenamed", id, name }); + } + return { status: "done" }; + } + + function references( + active: Session, + state: Extract, + id: string, + ) { + return ( + (state.displayed.type === "snapshot" && state.displayed.id === id) || + (state.pending?.type === "snapshot" && state.pending.id === id) || + (state.pending !== undefined && active.selectionBaseline === id) || + state.compareTo === id + ); + } + + return { + store, + open, + close, + list, + loadMore, + select, + restore, + rename, + get canCompare() { + return adapter.supportsComparison; + }, + get historyIncludesBeginning() { + return options.storage.historyIncludesBeginning === true; + }, + get canCreate() { + return options.storage.create !== undefined; + }, + get canRename() { + return options.storage.rename !== undefined; + }, + get canRestore() { + return options.storage.restore !== undefined; + }, + get canRemove() { + return options.storage.remove !== undefined; + }, + async create(this: void, name?: string): Promise { + const active = session; + const storage = options.storage; + if (!active || restoring || !storage.create) { + return { status: "unavailable" }; + } + const current = active.view.current; + const result = await storage.create( + current.content, + name, + current.capturedAt, + ); + if (!result.ok) { + return { status: "error", error: result.error }; + } + await refreshAfterNaming(active, { + type: "snapshotCreated", + snapshot: result.value, + }); + return { status: "done" }; + }, + async remove(this: void, id: string): Promise { + const active = session; + const storage = options.storage; + if (!active || restoring || !storage.remove) { + return { status: "unavailable" }; + } + const removed = await storage.remove(id); + if (!removed.ok) { + return { status: "error", error: removed.error }; + } + if (!active.lifetime.signal.aborted) { + await list(); + // Missing page metadata does not prove deletion. Continuous-history + // providers may only clear a name, leaving its content available. + const state = store.state; + if (state.mode === "versions" && references(active, state, id)) { + const content = await storage.getContent(id, active.lifetime.signal); + const current = store.state; + if ( + !active.lifetime.signal.aborted && + !content.ok && + content.error.type === "not-found" && + current.mode === "versions" && + references(active, current, id) + ) { + await select( + (current.displayed.type === "snapshot" && + current.displayed.id === id) || + (current.pending?.type === "snapshot" && + current.pending.id === id) + ? { type: "current" } + : current.displayed, + ); + } + } + } + return { status: "done" }; + }, + dispose() { + disposed = true; + close(); + }, + }; +} diff --git a/packages/core/src/extensions/Versioning/formatVersionDate.ts b/packages/core/src/extensions/Versioning/formatVersionDate.ts new file mode 100644 index 0000000000..c90d073d8c --- /dev/null +++ b/packages/core/src/extensions/Versioning/formatVersionDate.ts @@ -0,0 +1,24 @@ +// One formatter per locale: building an `Intl.DateTimeFormat` is the costly +// part, and history lists format a date per row on every render. +const formatters = new Map(); + +/** + * A version's date and time as one string. A single formatter lets the locale + * order and join the parts itself (e.g. "3 January 2024 at 9:44", + * "3. Januar 2024 um 9:44"), rather than a hardcoded "date, time". + * @param locale - Defaults to the browser's locale. + */ +export function formatVersionDate(timestamp: number, locale?: string): string { + let formatter = formatters.get(locale); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "long", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + formatters.set(locale, formatter); + } + return formatter.format(timestamp); +} diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts index 8d9c7567eb..4c5a3b85eb 100644 --- a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts @@ -1,469 +1,572 @@ -/** - * @vitest-environment jsdom - */ +// @vitest-environment node +import { resultValue } from "./__test__/result.js"; +import { expect, expectTypeOf, it } from "vite-plus/test"; import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; - + closeHistory, + history, + undo, + undoDepth, + redo, +} from "@tiptap/pm/history"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/blocks/createSpec.js"; +import { createInlineContentSpec } from "../../schema/inlineContent/createSpec.js"; +import { createStyleSpec } from "../../schema/styles/createSpec.js"; import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; -import { CURRENT_VERSION_ID, VersioningExtension } from "./Versioning.js"; import { - createInMemoryPreviewController, - createInMemoryVersioningAdapter, - createInMemoryVersioningEndpoints, + createVersioningExtension, + type VersioningController, +} from "./Versioning.js"; +import { + createLocalVersioning, + type LocalVersioningOptions, + type ProseMirrorDocumentJSON, + InMemoryVersioningExtension, } from "./inMemoryVersioning.js"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createEditor() { - const editor = BlockNoteEditor.create(); - const div = document.createElement("div"); - editor.mount(div); - return editor; -} - -function getEditorText(editor: BlockNoteEditor): string { - return editor.prosemirrorState.doc.textContent; -} - -function setEditorText(editor: BlockNoteEditor, text: string) { - editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]); -} - -// --------------------------------------------------------------------------- -// Tests — createInMemoryVersioningEndpoints -// --------------------------------------------------------------------------- - -describe("createInMemoryVersioningEndpoints", () => { - it("creates and retrieves snapshots", async () => { - const endpoints = createInMemoryVersioningEndpoints(); - const blocks = [ - { - id: "1", - type: "paragraph" as const, - content: [] as any, - props: {} as any, - children: [], - }, - ]; - - const snap = await endpoints.create!(blocks, { name: "v1" }); - expect(snap.name).toBe("v1"); - expect(snap.id).toBeDefined(); - - const content = await endpoints.getContent(snap); - expect(content).toEqual(blocks); - // Content is a deep clone, not a reference - expect(content).not.toBe(blocks); - }); - - it("lists snapshots newest-first", async () => { - vi.useFakeTimers(); +it.each(["current", "snapshot"] as const)( + "compares %s against the earliest saved content since beginning", + async (target) => { + const editor = BlockNoteEditor.create({ + initialContent: [{ id: "paragraph", type: "paragraph" }], + extensions: [ + DiffVersioningExtension(), + InMemoryVersioningExtension({ + initialVersions: ["a", "abc"].map((content, index) => ({ + content: [{ id: "paragraph", type: "paragraph", content }], + createdAt: index + 1, + })), + }), + ], + }); + const mode = editor.getExtension(InMemoryVersioningExtension)!; try { - const endpoints = createInMemoryVersioningEndpoints(); - - const s1 = await endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, + for (const content of ["a", "ab", "abc"]) { + editor.updateBlock("paragraph", { content }); + } + mode.open(); + expect(await mode.list()).toEqual({ status: "done" }); + const state = mode.store.state; + if (state.mode !== "versions" || state.history.status !== "success") { + throw new Error("Expected loaded version history"); + } + // The menu uses the oldest recorded snapshot as its baseline. Its + // content already contains "a", so only later edits are insertions. + const beginning = state.history.data.at(-1)!; + expect( + await mode.select( + target === "current" + ? { type: "current" } + : { type: "snapshot", id: state.history.data[0].id }, + { compareTo: beginning.id }, + ), + ).toEqual({ status: "done" }); + + const characters: Array<{ character: string; inserted: boolean }> = []; + editor.prosemirrorState.doc.descendants((node) => { + if (node.isText) { + for (const character of node.text ?? "") { + characters.push({ + character, + inserted: node.marks.some( + (mark) => mark.type.name === "y-attributed-insert", + ), + }); + } + } + }); + expect(characters).toEqual([ + { character: "a", inserted: false }, + { character: "b", inserted: true }, + { character: "c", inserted: true }, ]); - vi.advanceTimersByTime(1000); - const s2 = await endpoints.create!([ - { - id: "2", - type: "paragraph" as const, - content: "v2" as any, - props: {} as any, - children: [], - }, - ]); - - const list = await endpoints.list(); - expect(list[0].id).toBe(s2.id); - expect(list[1].id).toBe(s1.id); } finally { - vi.useRealTimers(); + mode.dispose(); + editor._tiptapEditor.destroy(); } - }); - - it("restore creates a backup and returns snapshot content", async () => { - const endpoints = createInMemoryVersioningEndpoints(); + }, +); - const original = [ - { - id: "1", - type: "paragraph" as const, - content: "original" as any, - props: {} as any, - children: [], - }, - ]; - const snap = await endpoints.create!(original); - - const currentDoc = [ - { - id: "2", - type: "paragraph" as const, - content: "modified" as any, - props: {} as any, - children: [], - }, - ]; - const restored = await endpoints.restore!(currentDoc, snap); - - expect(restored).toEqual(original); - - // A backup snapshot was created - const list = await endpoints.list(); - expect(list.length).toBe(2); - const backup = list.find((s) => s.restoredFromSnapshotId === snap.id); - expect(backup).toBeDefined(); - - // The backup contains the current (pre-restore) doc - const backupContent = await endpoints.getContent(backup!); - expect(backupContent).toEqual(currentDoc); - }); - - it("updates snapshot name", async () => { - const endpoints = createInMemoryVersioningEndpoints(); - const snap = await endpoints.create!( - [ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, +it("uses saved content for both previews and comparison baselines", async () => { + const editor = BlockNoteEditor.create(); + try { + const { storage } = createLocalVersioning(editor, { + initialVersions: [ + { content: [{ content: "abc" }], createdAt: 2 }, + { content: [{ content: "a" }], createdAt: 1 }, ], - { name: "old" }, + }); + const signal = new AbortController().signal; + const earliest = resultValue(await storage.getContent("2", signal)); + expect(earliest.textContent).toBe("a"); + earliest.check(); + expect(resultValue(await storage.getContent("1", signal)).textContent).toBe( + "abc", ); + expect(await storage.getContent("missing", signal)).toEqual({ + ok: false, + error: { type: "not-found" }, + }); + } finally { + editor._tiptapEditor.destroy(); + } +}); - await endpoints.rename!(snap, "new"); - - const list = await endpoints.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("new"); - }); - - it("deletes a snapshot and its content", async () => { - const endpoints = createInMemoryVersioningEndpoints(); - const snap = await endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, +it("returns seeded versions newest first regardless of insertion order", async () => { + const editor = BlockNoteEditor.create(); + try { + const { storage } = createLocalVersioning(editor, { + initialVersions: [10, 30, 20].map((createdAt) => ({ + content: [{ type: "paragraph" }], + createdAt, + })), + }); + const page = resultValue(await storage.list(new AbortController().signal)); + expect(page.snapshots.map(({ createdAt }) => createdAt)).toEqual([ + 30, 20, 10, ]); - - await endpoints.remove!(snap); - - // No longer listed - expect(await endpoints.list()).toHaveLength(0); - // Its content is gone too - await expect(endpoints.getContent(snap)).rejects.toThrow(/not found/i); - }); - - it("throws for unknown snapshot ID", async () => { - const endpoints = createInMemoryVersioningEndpoints(); - const missing = { id: "nope", createdAt: 0, updatedAt: 0 }; - await expect(endpoints.getContent(missing)).rejects.toThrow(/not found/i); - await expect(endpoints.restore!([], missing)).rejects.toThrow(/not found/i); - await expect(endpoints.rename!(missing, "x")).rejects.toThrow(/not found/i); - await expect(endpoints.remove!(missing)).rejects.toThrow(/not found/i); - }); + } finally { + editor._tiptapEditor.destroy(); + } }); -// --------------------------------------------------------------------------- -// Tests — createInMemoryPreviewController -// --------------------------------------------------------------------------- - -describe("createInMemoryPreviewController", () => { - let editor: BlockNoteEditor; - - beforeEach(() => { - editor = createEditor(); - setEditorText(editor, "live content"); - }); - - afterEach(() => { - editor.unmount(); - }); - - it("enterPreview replaces doc and exitPreview restores it", () => { - const preview = createInMemoryPreviewController(editor); - - // Grab the snapshot content we want to preview — a doc with different text. - const previewEditor = createEditor(); - setEditorText(previewEditor, "snapshot content"); - const snapshotBlocks = previewEditor.document; - previewEditor.unmount(); - - preview.enterPreview(snapshotBlocks); - expect(getEditorText(editor)).toBe("snapshot content"); - - preview.exitPreview(); - expect(getEditorText(editor)).toBe("live content"); +it("previews and restores JSON exported from another editor's schema", async () => { + const source = BlockNoteEditor.create({ + initialContent: [ + { id: "paragraph", type: "paragraph", content: "Old text" }, + ], }); - - it("successive enterPreview calls preserve original doc", () => { - const preview = createInMemoryPreviewController(editor); - - const mkSnap = (text: string) => { - const e = createEditor(); - setEditorText(e, text); - const blocks = e.document; - e.unmount(); - return blocks; - }; - - preview.enterPreview(mkSnap("snap A")); - expect(getEditorText(editor)).toBe("snap A"); - - preview.enterPreview(mkSnap("snap B")); - expect(getEditorText(editor)).toBe("snap B"); - - // Exit restores the original live doc, not snap A. - preview.exitPreview(); - expect(getEditorText(editor)).toBe("live content"); - }); - - it("applyRestore replaces doc and clears saved state", () => { - const preview = createInMemoryPreviewController(editor); - - const mkSnap = (text: string) => { - const e = createEditor(); - setEditorText(e, text); - const blocks = e.document; - e.unmount(); - return blocks; - }; - - // Enter preview first - preview.enterPreview(mkSnap("previewed")); - expect(getEditorText(editor)).toBe("previewed"); - - // Now restore — this is the "apply" step after endpoints.restore returns - preview.applyRestore(mkSnap("restored")); - expect(getEditorText(editor)).toBe("restored"); - - // exitPreview should be a no-op since savedDoc was cleared - preview.exitPreview(); - expect(getEditorText(editor)).toBe("restored"); + const sourceSchema = source.pmSchema; + const before = source.prosemirrorState.doc.toJSON(); + source.updateBlock("paragraph", { content: "Changed text" }); + const after = source.prosemirrorState.doc.toJSON(); + source._tiptapEditor.destroy(); + const editor = BlockNoteEditor.create({ + extensions: [ + InMemoryVersioningExtension({ + initialVersions: [ + { content: before, createdAt: 1 }, + { content: after, createdAt: 2 }, + ], + }), + ], }); + const mode = editor.getExtension(InMemoryVersioningExtension)!; + try { + expect(mode).toBeDefined(); + expect(editor.pmSchema).not.toBe(sourceSchema); + mode.open(); + await mode.list(); + expect(mode.store.state).toMatchObject({ + showCurrentVersion: true, + displayed: { type: "current" }, + }); + await mode.select({ type: "snapshot", id: "2" }); + expect(editor.prosemirrorState.doc.textContent).toBe("Changed text"); + await mode.select({ type: "snapshot", id: "1" }); + expect(editor.prosemirrorState.doc.textContent).toBe("Old text"); + await mode.restore("2"); + expect(editor.prosemirrorState.doc.textContent).toBe("Changed text"); + expect(editor.isEditable).toBe(true); + } finally { + mode?.dispose(); + editor._tiptapEditor.destroy(); + } }); -// --------------------------------------------------------------------------- -// Tests — Full integration with VersioningExtension -// --------------------------------------------------------------------------- - -describe("VersioningExtension + in-memory adapter", () => { - let editor: BlockNoteEditor; - - beforeEach(() => { - editor = createEditor(); - setEditorText(editor, "initial doc"); - }); - - afterEach(() => { - editor.unmount(); - }); - - it("create, preview, exit, restore full workflow", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - // 1. Create a snapshot of "initial doc" - const snap1 = await ext.create!({ name: "v1" }); - expect(snap1.name).toBe("v1"); - - // 2. Modify the document - setEditorText(editor, "modified doc"); - - // 3. Create another snapshot - await ext.create!({ name: "v2" }); - - // 4. List — both present (the adapter also surfaces a "current version" - // entry, which isn't a stored snapshot). - const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID); - expect(list).toHaveLength(2); - expect(list.map((s) => s.name)).toContain("v1"); - expect(list.map((s) => s.name)).toContain("v2"); - - // 5. Preview the first snapshot - await ext.previewSnapshot(snap1.id); - expect(getEditorText(editor)).toBe("initial doc"); - expect(ext.store.state.previewedSnapshotId).toBe(snap1.id); - - // 6. Exit preview — back to modified doc - ext.exitPreview(); - expect(getEditorText(editor)).toBe("modified doc"); - expect(ext.store.state.previewedSnapshotId).toBeUndefined(); - - // 7. Restore the first snapshot - const restored = await ext.restore!(snap1.id); - expect(restored).toBeDefined(); - expect(getEditorText(editor)).toBe("initial doc"); - - // 8. A backup snapshot was created by the endpoints (plus the adapter's - // "current version" entry, which isn't a stored snapshot). - const afterRestore = (await ext.list()).filter( - (s) => s.id !== CURRENT_VERSION_ID, +it("converts partial-block seeds without changing live content, selection, or undo", async () => { + const blocks: PartialBlock[] = [ + { + id: "saved", + content: "Saved text", + children: [{ type: "paragraph", content: "Nested text" }], + }, + ]; + const editor = BlockNoteEditor.create(); + try { + // Headless editors do not mount their plugins. Install history explicitly + // so this test exercises real undo state rather than comparing zero depths. + editor.prosemirrorView.updateState( + editor.prosemirrorState.reconfigure({ + plugins: [...editor.prosemirrorState.plugins, history()], + }), ); - expect(afterRestore.length).toBe(3); - const backup = afterRestore.find( - (s) => s.restoredFromSnapshotId === snap1.id, + editor.replaceBlocks(editor.document, [{ content: "Live text" }]); + editor.transact((tr) => closeHistory(tr)); + const before = editor.prosemirrorState; + expect(undoDepth(before)).toBeGreaterThan(0); + const { adapter, storage } = createLocalVersioning(editor, { + initialVersions: [{ content: blocks, name: "Draft", createdAt: 123 }], + }); + expect(editor.prosemirrorState).toBe(before); + const signal = new AbortController().signal; + expect(resultValue(await storage.list(signal)).snapshots).toEqual([ + { id: "1", name: "Draft", createdAt: 123 }, + ]); + const content = resultValue(await storage.getContent("1", signal)); + expect(content.type.schema).toBe(editor.pmSchema); + expect(content.textContent).toBe("Saved textNested text"); + const preview = adapter.open(); + preview.show({ content, target: { type: "snapshot", id: "1" } }); + expect(editor.document[0].id).toBe("saved"); + expect(editor.document[0].type).toBe("paragraph"); + expect(editor.document[0].children[0].id).toBeTruthy(); + preview.close(); + expect(editor.prosemirrorState.doc).toBe(before.doc); + expect(editor.prosemirrorState.selection.eq(before.selection)).toBe(true); + expect(undoDepth(editor.prosemirrorState)).toBe(undoDepth(before)); + await storage.restore!("1"); + expect(editor.prosemirrorState.doc.textContent).toBe( + "Saved textNested text", ); - expect(backup).toBeDefined(); - }); - - it("preview with compareTo fetches both contents", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - const snap1 = await ext.create!({ name: "baseline" }); - setEditorText(editor, "changed doc"); - const snap2 = await ext.create!({ name: "current" }); - - // Preview snap2 compared to snap1. Without the (opt-in) DiffVersioningExtension - // registered, the in-memory preview controller falls back to a static swap: - // it shows the snapshot content and renders no diff marks. - await ext.previewSnapshot(snap2.id, { compareTo: snap1.id }); - expect(getEditorText(editor)).toBe("changed doc"); - - ext.exitPreview(); - expect(getEditorText(editor)).toBe("changed doc"); - }); - - it("delete removes the snapshot from the store and backend", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - const snap1 = await ext.create!({ name: "keep" }); - setEditorText(editor, "changed doc"); - const snap2 = await ext.create!({ name: "remove" }); - await ext.list(); - - expect(ext.canRemove).toBe(true); - await ext.remove!(snap2.id); - - // Gone from the optimistic store... - expect( - ext.store.state.snapshots.find((s) => s.id === snap2.id), - ).toBeUndefined(); - // ...and gone from the backend's authoritative list. - const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID); - expect(list.map((s) => s.id)).toEqual([snap1.id]); - }); - - it("deleting the previewed snapshot exits preview", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - const snap = await ext.create!({ name: "v1" }); - setEditorText(editor, "modified doc"); - - // Preview the snapshot, then delete the version being previewed. - await ext.previewSnapshot(snap.id); - expect(ext.store.state.previewedSnapshotId).toBe(snap.id); - - await ext.remove!(snap.id); - - // Preview was exited and the live document restored. - expect(ext.store.state.previewedSnapshotId).toBeUndefined(); - expect(getEditorText(editor)).toBe("modified doc"); - }); - - it("rename persists through list refresh", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - const snap = await ext.create!({ name: "draft" }); - await ext.rename!(snap.id, "final"); - - // Store was updated optimistically - expect(ext.store.state.snapshots.find((s) => s.id === snap.id)!.name).toBe( - "final", + expect(undo(editor.prosemirrorState, editor.prosemirrorView.dispatch)).toBe( + true, ); - - // Backend also updated (verified via list which calls endpoints.list) - const list = await ext.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("final"); - }); + expect(editor.prosemirrorState.doc).toEqual(before.doc); + } finally { + editor._tiptapEditor.destroy(); + } }); -// --------------------------------------------------------------------------- -// Tests — diff delegation to the opt-in DiffVersioningExtension -// --------------------------------------------------------------------------- - -describe("in-memory versioning + DiffVersioningExtension", () => { - let editor: BlockNoteEditor; - - beforeEach(() => { - editor = BlockNoteEditor.create({ - extensions: [DiffVersioningExtension()], +it("loads table content and attributes from ProseMirror JSON seeds", async () => { + const widths = [120]; + const text = { type: "text", text: "Cell text" }; + const content: ProseMirrorDocumentJSON = { + type: "doc", + content: [ + { + type: "blockGroup", + content: [ + { + type: "blockContainer", + attrs: { id: "table" }, + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colwidth: widths }, + content: [{ type: "tableParagraph", content: [text] }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const editor = BlockNoteEditor.create(); + try { + const { storage } = createLocalVersioning(editor, { + initialVersions: [{ content, createdAt: 1 }], }); - editor.mount(document.createElement("div")); - setEditorText(editor, "initial doc"); - }); + const document = resultValue( + await storage.getContent("1", new AbortController().signal), + ); + expect(document.type.schema).toBe(editor.pmSchema); + expect(document.textContent).toBe("Cell text"); + document.descendants((node) => { + if (node.type.name === "tableCell") { + expect(node.attrs.colwidth).toEqual([120]); + } + }); + } finally { + editor._tiptapEditor.destroy(); + } +}); - afterEach(() => { - editor.unmount(); +it("uses custom block, inline-content, and style schemas for partial-block seeds", async () => { + const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createBlockSpec( + { + type: "callout", + content: "inline", + propSchema: { tone: { default: "info" } }, + }, + { render: () => ({ dom: document.createElement("div") }) }, + )(), + }, + inlineContentSpecs: { + mention: createInlineContentSpec( + { + type: "mention", + content: "none", + propSchema: { label: { default: "Ada" } }, + }, + { render: () => ({ dom: document.createElement("span") }) }, + ), + }, + styleSpecs: { + highlight: createStyleSpec( + { type: "highlight", propSchema: "boolean" }, + { render: () => ({ dom: document.createElement("span") }) }, + ), + }, }); - - const attributionMarkCount = () => { - let count = 0; - editor.prosemirrorState.doc.descendants((node) => { - count += node.marks.filter((m) => - m.type.name.startsWith("y-attributed-"), - ).length; - return true; + type B = typeof schema.blockSchema; + type I = typeof schema.inlineContentSchema; + type S = typeof schema.styleSchema; + expectTypeOf(InMemoryVersioningExtension) + .parameter(0) + .toEqualTypeOf | undefined>(); + type SeedContent = NonNullable< + LocalVersioningOptions["initialVersions"] + >[number]["content"]; + expectTypeOf< + { type: "callout"; props: { tone: number } }[] + >().not.toExtend(); + expectTypeOf<{ + type: "paragraph"; + content: string; + }>().not.toExtend(); + const blocks: PartialBlock[] = [ + { + id: "custom", + type: "callout", + content: [ + { type: "text", text: "Custom text", styles: { highlight: true } }, + { type: "mention", props: { label: "Ada" } }, + ], + }, + ]; + const editor = BlockNoteEditor.create({ + schema, + extensions: [ + InMemoryVersioningExtension({ + initialVersions: [{ content: blocks, createdAt: 1 }], + }), + ], + }); + const mode = editor.getExtension(InMemoryVersioningExtension)!; + try { + const local = createLocalVersioning(editor, { + initialVersions: [{ content: blocks, createdAt: 1 }], }); - return count; - }; - - it("previewing with compareTo renders an attributed diff", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); - - const snap1 = await ext.create!({ name: "baseline" }); - setEditorText(editor, "changed doc"); - const snap2 = await ext.create!({ name: "current" }); - - await ext.previewSnapshot(snap2.id, { compareTo: snap1.id }); + expect( + resultValue( + await local.storage.getContent("1", new AbortController().signal), + ).type.schema, + ).toBe(editor.pmSchema); + mode.open(); + await mode.select({ type: "snapshot", id: "1" }); + expect(editor.document[0]).toMatchObject({ + id: "custom", + type: "callout", + props: { tone: "info" }, + content: blocks[0].content, + }); + mode.close(); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } +}); - // The diff extension rendered attribution marks (initial vs changed doc). - expect(attributionMarkCount()).toBeGreaterThan(0); +it.each( + ( + [ + [], + { type: "doc", content: [] }, + { type: "doc", content: [{ type: "paragraph" }] }, + { type: "doc", content: [{ type: "unknownNode" }] }, + ] satisfies Array + ).map((content) => ({ content })), +)( + "rejects seeds that do not form a document in the receiving schema: $content", + ({ content }) => { + const editor = BlockNoteEditor.create(); + try { + expect(() => + createLocalVersioning(editor, { + initialVersions: [{ content, createdAt: 1 }], + }), + ).toThrow(); + } finally { + editor._tiptapEditor.destroy(); + } + }, +); - // Exiting the preview clears the marks and restores the live document. - ext.exitPreview(); - expect(attributionMarkCount()).toBe(0); - expect(getEditorText(editor)).toBe("changed doc"); +it("reads seeds at editor construction and gives each editor its own history", async () => { + const blocks: PartialBlock[] = [{ id: "saved", content: "First history" }]; + const extension = InMemoryVersioningExtension({ + initialVersions: [{ content: blocks, name: "Draft", createdAt: 123 }], }); + const first = BlockNoteEditor.create({ extensions: [extension] }); + blocks[0].content = "Second history"; + const second = BlockNoteEditor.create({ extensions: [extension] }); + blocks[0].content = "Later edit"; + const firstMode = first.getExtension(InMemoryVersioningExtension)!; + const secondMode = second.getExtension(InMemoryVersioningExtension)!; + try { + firstMode.open(); + await firstMode.select({ type: "snapshot", id: "1" }); + expect(first.prosemirrorState.doc.textContent).toBe("First history"); + await firstMode.rename("1", "Renamed"); + secondMode.open(); + await secondMode.list(); + await secondMode.select({ type: "snapshot", id: "1" }); + expect(second.prosemirrorState.doc.textContent).toBe("Second history"); + expect(first.prosemirrorState.doc.textContent).toBe("First history"); + const state = secondMode.store.state; + expect(state.mode).toBe("versions"); + if (state.mode === "versions") { + expect(state.history).toEqual({ + status: "success", + data: [{ id: "1", name: "Draft", createdAt: 123 }], + }); + } + } finally { + firstMode.dispose(); + secondMode.dispose(); + first._tiptapEditor.destroy(); + second._tiptapEditor.destroy(); + } +}); - it("previewing without compareTo shows content with no diff marks", async () => { - const adapter = createInMemoryVersioningAdapter(editor); - const ext = VersioningExtension(adapter)({ editor }); +it("finds the built-in extension by its factory with default options", () => { + const editor = BlockNoteEditor.create({ + extensions: [InMemoryVersioningExtension()], + }); + try { + const mode = editor.getExtension(InMemoryVersioningExtension); + expect(mode).toBeDefined(); + expect(mode).toBe(editor.getExtension("versioning")); + } finally { + editor._tiptapEditor.destroy(); + } +}); - const snap1 = await ext.create!({ name: "baseline" }); - setEditorText(editor, "changed doc"); +it("isolates displayed content and preserves live undo across open/show/close", async () => { + const extension = createVersioningExtension(createLocalVersioning); + const editor = BlockNoteEditor.create({ extensions: [extension()] }); + const mode = editor.getExtension(extension)!; + try { + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Live" }, + ]); + const before = editor.prosemirrorState; + mode.open(); + expect(editor.isEditable).toBe(false); + expect(await mode.create("Captured")).toEqual({ status: "done" }); + const state = mode.store.state; + if (state.mode !== "versions" || !state.history.data?.[0]) { + throw new Error("Expected a created version"); + } + const saved = state.history.data[0]; + await mode.select({ type: "snapshot", id: saved.id }); + mode.close(); + expect(editor.prosemirrorState.doc).toBe(before.doc); + expect(undoDepth(editor.prosemirrorState)).toBe(undoDepth(before)); + expect(editor.isEditable).toBe(true); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } +}); - await ext.previewSnapshot(snap1.id); +it("restores into the saved live state, then closes the isolated view", async () => { + const extension = createVersioningExtension(createLocalVersioning); + const editor = BlockNoteEditor.create({ extensions: [extension()] }); + const mode = editor.getExtension(extension)!; + try { + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Original" }, + ]); + mode.open(); + expect(await mode.create()).toEqual({ status: "done" }); + const state = mode.store.state; + if (state.mode !== "versions" || !state.history.data?.[0]) { + throw new Error("Expected a created version"); + } + const saved = state.history.data[0]; + mode.close(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "Latest" }, + ]); + mode.open(); + await mode.select({ type: "snapshot", id: saved.id }); + await mode.select({ type: "current" }); + expect(editor.prosemirrorState.doc.textContent).toBe("Latest"); + await mode.restore(saved.id); + expect(editor.prosemirrorState.doc.textContent).toBe("Original"); + expect(editor.isEditable).toBe(true); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } +}); - expect(getEditorText(editor)).toBe("initial doc"); - expect(attributionMarkCount()).toBe(0); +it("clears local undo and redo through previews and restores without preview history", async () => { + const editor = BlockNoteEditor.create({ + initialContent: [{ id: "paragraph", content: "original" }], + extensions: [ + InMemoryVersioningExtension({ + initialVersions: ["snapshot one", "snapshot two"].map( + (content, index) => ({ + createdAt: index + 1, + content: [{ id: "paragraph", content }], + }), + ), + }), + ], }); + editor.prosemirrorView.updateState( + editor.prosemirrorState.reconfigure({ plugins: [history()] }), + ); + const mode = editor.getExtension(InMemoryVersioningExtension)!; + const dispatch = editor.prosemirrorView.dispatch; + function text() { + return editor.prosemirrorState.doc.textContent; + } + function edit(content: string) { + editor.transact((tr) => closeHistory(tr)); + editor.updateBlock("paragraph", { content }); + } + try { + edit("live"); + expect(undoDepth(editor.prosemirrorState)).toBeGreaterThan(0); + mode.open(); + expect(editor.isEditable).toBe(false); + expect(history().spec.key!.get(editor.prosemirrorState)).toBeUndefined(); + for (const id of ["1", "2"]) { + await mode.select({ type: "snapshot", id }); + expect(text()).toBe(id === "1" ? "snapshot one" : "snapshot two"); + } + await mode.select({ type: "current" }); + expect(text()).toBe("live"); + mode.close(); + expect(undo(editor.prosemirrorState, dispatch)).toBe(false); + expect(redo(editor.prosemirrorState, dispatch)).toBe(false); + edit("new live"); + expect(undo(editor.prosemirrorState, dispatch)).toBe(true); + expect(text()).toBe("live"); + expect(redo(editor.prosemirrorState, dispatch)).toBe(true); + expect(text()).toBe("new live"); + mode.open(); + await mode.select({ type: "snapshot", id: "2" }); + await mode.restore("1"); + expect(text()).toBe("snapshot one"); + expect(editor.isEditable).toBe(true); + expect(undo(editor.prosemirrorState, dispatch)).toBe(false); + expect(redo(editor.prosemirrorState, dispatch)).toBe(false); + edit("after restore"); + expect(undo(editor.prosemirrorState, dispatch)).toBe(true); + expect(text()).toBe("snapshot one"); + expect(redo(editor.prosemirrorState, dispatch)).toBe(true); + expect(text()).toBe("after restore"); + } finally { + mode.dispose(); + editor._tiptapEditor.destroy(); + } }); diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts index 75aae103d0..71a6ecea06 100644 --- a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts @@ -1,273 +1,268 @@ +import type { Node } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { Block } from "../../blocks/defaultBlocks.js"; +import { originalFactorySymbol } from "../../editor/managers/ExtensionManager/symbol.js"; +import type { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, +} from "../../blocks/defaultBlocks.js"; +import type { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { blockToNode } from "../../api/nodeConversions/blockToNode.js"; +import { docToBlocks } from "../../api/nodeConversions/nodeToBlock.js"; import type { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; import type { - PreviewController, - VersioningEndpoints, - VersioningExtensionOptions, VersionSnapshot, -} from "./Versioning.js"; -import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst } from "./Versioning.js"; - -/** - * Label shown on a diff's marks for the version that introduced the changes. - * The previewed snapshot is the "new" side of the diff; the current-version - * entry (previewing the live doc) has no name, so it reads "Current version". - */ -function versionLabel(snapshot: VersionSnapshot): string { - if (snapshot.id === CURRENT_VERSION_ID) { - return "Current version"; - } - return snapshot.name ?? "Unnamed version"; -} - -// --------------------------------------------------------------------------- -// Preview Controller -// --------------------------------------------------------------------------- - -/** - * Create a {@link PreviewController} that swaps the BlockNote document in and - * out using `editor.replaceBlocks`. - * - * When entering preview mode the current document is saved so it can be - * restored on exit. Successive `enterPreview` calls without an intervening - * `exitPreview` preserve the original saved document. - */ -export function createInMemoryPreviewController( - editor: BlockNoteEditor, -): PreviewController[]> { - let savedDoc: Block[] | undefined; - // True while a diff (attribution marks) is on screen, so exit/restore knows to - // route the cleanup through the diff extension's node-view rebuild. - let showingDiff = false; + VersionStorage, + VersionViewAdapter, +} from "./types.js"; +import { createVersioningExtension } from "./Versioning.js"; +import type { UserStoreOrResolver } from "../../user/index.js"; - const replaceDoc = (blocks: Block[]) => { - editor.replaceBlocks(editor.document, blocks); - }; +/** ProseMirror JSON uses schema-defined node/mark names and attribute values. */ +export type ProseMirrorNodeJSON = { + type: string; + attrs?: Record; + content?: ProseMirrorNodeJSON[]; + marks?: Array<{ type: string; attrs?: Record }>; + text?: string; +}; - // The opt-in diff extension, if the consuming editor registered it. Looked up - // by key so this module keeps zero runtime dependency on `@y/*`. - const getDiff = () => - editor.getExtension("diffVersioning"); +export type ProseMirrorDocumentJSON = ProseMirrorNodeJSON & { type: "doc" }; - return { - // Comparison is only possible when the (opt-in) diff extension is present — - // otherwise previewing a comparison just statically shows the snapshot, so - // the UI shouldn't offer it. A getter (not a static `true`) so it's - // independent of the order the extensions were registered in: the diff - // extension is typically added after the versioning extension, and this is - // read lazily (on render) once both are registered. - get supportsComparison() { - return getDiff() !== undefined; - }, - enterPreview( - snapshotContent: Block[], - compareToContent?: Block[], - _attributions?: unknown, - context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, - ) { - // Save the live doc on first enter (successive enters keep the original). - if (savedDoc === undefined) { - savedDoc = editor.document; - } +export type LocalVersioningSeedOptions< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> = { + initialVersions?: Array<{ + /** A partial-block array or ProseMirror document JSON, valid in this editor's schema. */ + content: + | PartialBlock[] + | ProseMirrorDocumentJSON; + name?: string; + createdAt: number; + }>; +}; - const diff = getDiff(); - if (compareToContent && diff) { - // Render a diff of compareTo → snapshot, labelling the changes with the - // previewed version's name (the diff's single "author"). - diff.renderDiff( - snapshotContent, - compareToContent, - context && versionLabel(context.snapshot), - ); - showingDiff = true; - return; - } +export type LocalVersioningOptions< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> = LocalVersioningSeedOptions & { + resolveUsers?: UserStoreOrResolver; + scrollToFirstChange?: boolean; +}; - // No comparison requested, or no diff extension registered: just show the - // snapshot content statically. - showingDiff = false; - replaceDoc(snapshotContent); - }, - - exitPreview() { - if (savedDoc !== undefined) { - const diff = getDiff(); - if (showingDiff && diff) { - diff.clearDiff(savedDoc); - } else { - replaceDoc(savedDoc); - } - savedDoc = undefined; - showingDiff = false; - } - }, - - applyRestore(snapshotContent: Block[]) { - const diff = getDiff(); - if (showingDiff && diff) { - diff.clearDiff(snapshotContent); - } else { - replaceDoc(snapshotContent); - } - // Clear saved doc — the restored content is now the live document. - savedDoc = undefined; - showingDiff = false; - }, +/** Install an independent in-memory history for this editor. */ +export function InMemoryVersioningExtension< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +>(options?: LocalVersioningOptions) { + return function createLocalVersioningExtension({ + editor, + }: { + editor: BlockNoteEditor; + }) { + const extension = createVersioningExtension(() => ({ + ...createLocalVersioning(editor, options), + resolveUsers: options?.resolveUsers, + scrollToFirstChange: options?.scrollToFirstChange, + }))()({ editor }); + // Register the public factory for editor.getExtension(factory). + Object.assign(extension, { + [originalFactorySymbol]: InMemoryVersioningExtension, + }); + return extension; }; } -// --------------------------------------------------------------------------- -// Endpoints (in-memory storage) -// --------------------------------------------------------------------------- - -/** - * Create a {@link VersioningEndpoints} that stores snapshots entirely in - * memory. Useful for local-only / non-collaborative editors where you want - * versioning without any persistence layer. - * - * Snapshots are stored as BlockNote document JSON (`Block[]`). - */ -export function createInMemoryVersioningEndpoints(): VersioningEndpoints< - Block[], - Block[] -> { - const snapshots: VersionSnapshot[] = []; - const contents = new Map[]>(); - let nextId = 1; +/** A separate editor state preserves live selection and undo without preview mappings. */ +export function createLocalVersioning< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +>( + editor: BlockNoteEditor, + options?: LocalVersioningSeedOptions, +): { + adapter: VersionViewAdapter; + storage: VersionStorage; +} { + let live: EditorState | undefined; + let nextId = 0; + const snapshots = new Map< + string, + { version: VersionSnapshot; content: Node } + >(); + for (const entry of options?.initialVersions ?? []) { + const version = { + id: String(++nextId), + name: entry.name, + createdAt: entry.createdAt, + }; + const content = entry.content; + const document = Array.isArray(content) + ? editor.pmSchema.topNodeType.createChecked( + null, + editor.pmSchema.nodes.blockGroup.createChecked( + null, + content.map((block) => + blockToNode(block, editor.pmSchema, editor.schema.styleSchema), + ), + ), + ) + : editor.pmSchema.nodeFromJSON(content); + // Invalid seeds are configuration errors, not recoverable editor input. + // nodeFromJSON does not check the whole content tree; container block + // conversion is also intentionally lenient, so validate both paths here. + if (document.type !== editor.pmSchema.topNodeType) { + throw new Error("Version content must be a ProseMirror document"); + } + document.check(); + snapshots.set(version.id, { version, content: document }); + } - // `Date.now()` only has millisecond resolution, so two snapshots created in - // the same tick would share a timestamp and `sortSnapshotsNewestFirst` (which - // has nothing else to order on) could list them oldest-first. Hand out - // strictly increasing timestamps so creation order is always preserved. - let lastTimestamp = 0; - function nextTimestamp() { - lastTimestamp = Math.max(Date.now(), lastTimestamp + 1); - return lastTimestamp; + function inEditorSchema(content: Node): Node { + // ProseMirror matches node types by identity, not name. Seeded or loaded + // documents can come from another editor with a different schema instance. + return content.type.schema === editor.pmSchema + ? content + : editor.pmSchema.nodeFromJSON(content.toJSON()); } return { - async list() { - return sortSnapshotsNewestFirst([...snapshots]); - }, - - async create(currentDoc, options) { - const now = nextTimestamp(); - const id = String(nextId++); - const snapshot: VersionSnapshot = { - id, - name: options?.name, - createdAt: now, - updatedAt: now, - }; - snapshots.push(snapshot); - contents.set(id, structuredClone(currentDoc)); - return snapshot; - }, - - async restore(currentDoc, snapshot) { - // Stored snapshots always have string ids (only the synthetic current - // entry carries the symbol, and it never reaches these methods). - const id = String(snapshot.id); - const snapshotContent = contents.get(id); - if (!snapshotContent) { - throw new Error(`Snapshot ${id} not found`); - } - - // Create a "Restored from …" snapshot of the current state before - // restoring, so the user can undo the restore. - const now = nextTimestamp(); - const backupId = String(nextId++); - const backup: VersionSnapshot = { - id: backupId, - name: "Before restore", - createdAt: now, - updatedAt: now, - restoredFromSnapshotId: id, - }; - snapshots.push(backup); - contents.set(backupId, structuredClone(currentDoc)); - - return structuredClone(snapshotContent); - }, - - async getContent(snapshot) { - const id = String(snapshot.id); - const content = contents.get(id); - if (!content) { - throw new Error(`Snapshot ${id} not found`); - } - return structuredClone(content); - }, - - async rename(snapshot, name) { - const stored = snapshots.find((s) => s.id === snapshot.id); - if (!stored) { - throw new Error(`Snapshot ${String(snapshot.id)} not found`); - } - stored.name = name; - stored.updatedAt = nextTimestamp(); - }, - - async remove(snapshot) { - const index = snapshots.findIndex((s) => s.id === snapshot.id); - if (index === -1) { - throw new Error(`Snapshot ${String(snapshot.id)} not found`); - } - snapshots.splice(index, 1); - contents.delete(String(snapshot.id)); + adapter: { + get supportsComparison() { + return ( + editor.getExtension( + "diffVersioning", + ) !== undefined + ); + }, + open() { + if (live || editor.getExtension("ySync")) { + throw new Error( + "Local version views require an unbound local editor", + ); + } + live = editor.prosemirrorState; + const current = { content: live.doc, capturedAt: Date.now() }; + let closed = false; + try { + editor.prosemirrorView.updateState( + EditorState.create({ + doc: live.doc, + selection: live.selection, + plugins: live.plugins, + }), + ); + } catch (error) { + editor.prosemirrorView.updateState(live); + live = undefined; + throw error; + } + return { + current, + show({ content, comparison }) { + if (closed) { + throw new Error("Version view is closed"); + } + const diff = + editor.getExtension( + "diffVersioning", + ); + if (comparison && diff) { + diff.renderDiff( + docToBlocks(content), + docToBlocks(comparison.content), + ); + return; + } + const document = inEditorSchema(content); + editor.transact((tr) => { + tr.replaceWith(0, tr.doc.content.size, document.content); + tr.setMeta("addToHistory", false); + }); + }, + close() { + if (closed) { + return; + } + if (!live) { + throw new Error("Missing live editor state"); + } + editor.prosemirrorView.updateState( + live.reconfigure({ + plugins: editor.prosemirrorState.plugins, + }), + ); + closed = true; + live = undefined; + }, + }; + }, }, - }; -} - -// --------------------------------------------------------------------------- -// Adapter (convenience) -// --------------------------------------------------------------------------- - -/** - * Create all the options needed to wire a {@link VersioningExtension} with - * fully in-memory storage and BlockNote JSON-based preview. - * - * @example - * ```ts - * import { VersioningExtension } from "@blocknote/core/extensions"; - * import { createInMemoryVersioningAdapter } from "@blocknote/core/extensions"; - * - * const editor = BlockNoteEditor.create({ - * extensions: [ - * VersioningExtension(createInMemoryVersioningAdapter(editor)), - * ], - * }); - * ``` - */ -export function createInMemoryVersioningAdapter( - editor: BlockNoteEditor, -): VersioningExtensionOptions[], Block[]> { - const endpoints = createInMemoryVersioningEndpoints(); - - return { - // The raw endpoints are pure snapshot storage. The "current version" is a - // view concern owned by the adapter (it's the layer that knows about the - // live editor), so we wrap `list()` to always surface a current entry: the - // live document is the editable working copy, and the entry is how the user - // returns to live editing and compares against saved snapshots. No - // timestamp/author is tracked, so the row just reads "Current version" - // (see CurrentSnapshot in @blocknote/react). - endpoints: { - ...endpoints, - list: async () => { - const current: VersionSnapshot = { - id: CURRENT_VERSION_ID, - createdAt: Date.now(), - updatedAt: Date.now(), + storage: { + historyIncludesBeginning: true, + async list(signal) { + signal.throwIfAborted(); + return { + ok: true, + value: { + snapshots: Array.from(snapshots.values(), ({ version }) => ({ + ...version, + })).sort((a, b) => b.createdAt - a.createdAt), + }, }; - return [current, ...(await endpoints.list())]; + }, + async getContent(id, signal) { + signal.throwIfAborted(); + const stored = snapshots.get(id); + return stored + ? { ok: true, value: stored.content } + : { ok: false, error: { type: "not-found" } }; + }, + async create(content, name) { + const version = { id: String(++nextId), createdAt: Date.now(), name }; + snapshots.set(version.id, { version, content }); + return { ok: true, value: { ...version } }; + }, + async restore(id) { + const stored = snapshots.get(id); + if (!stored) { + return { ok: false, error: { type: "not-found" } }; + } + const content = inEditorSchema(stored.content); + if (live) { + live = live.apply( + live.tr.replaceWith(0, live.doc.content.size, content.content), + ); + } else { + editor.transact((tr) => + tr.replaceWith(0, tr.doc.content.size, content.content), + ); + } + return { ok: true, value: undefined }; + }, + async rename(id, name) { + const stored = snapshots.get(id); + if (!stored) { + return { ok: false, error: { type: "not-found" } }; + } + stored.version.name = name; + return { ok: true, value: undefined }; + }, + async remove(id) { + snapshots.delete(id); + return { ok: true, value: undefined }; }, }, - preview: createInMemoryPreviewController(editor), - getCurrentDocument: () => editor.document, - // The live document is already in the snapshot content format (`Block[]`), - // so previewing "current" as a diff just reuses the live blocks. - serializeCurrentContent: () => editor.document, }; } diff --git a/packages/core/src/extensions/Versioning/index.ts b/packages/core/src/extensions/Versioning/index.ts index c24920adc1..1c5df526ca 100644 --- a/packages/core/src/extensions/Versioning/index.ts +++ b/packages/core/src/extensions/Versioning/index.ts @@ -1,2 +1,6 @@ +export * from "./types.js"; +export * from "./createVersioning.js"; export * from "./Versioning.js"; export * from "./inMemoryVersioning.js"; +export * from "./scrollToFirstChange.js"; +export * from "./formatVersionDate.js"; diff --git a/packages/core/src/extensions/Versioning/scrollScheduling.test.ts b/packages/core/src/extensions/Versioning/scrollScheduling.test.ts new file mode 100644 index 0000000000..668f6d6265 --- /dev/null +++ b/packages/core/src/extensions/Versioning/scrollScheduling.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { + scheduleScrollToFirstChange, + SCROLL_TO_FIRST_CHANGE_DELAY_MS, +} from "./scrollToFirstChange.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +it("owns a cancellable timer and releases it before resolving the root", () => { + vi.useFakeTimers(); + const getRoot = vi.fn(() => undefined); + const cancel = scheduleScrollToFirstChange(getRoot); + expect(typeof cancel).toBe("function"); + expect(vi.getTimerCount()).toBe(1); + cancel?.(); + cancel?.(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(getRoot).not.toHaveBeenCalled(); +}); + +it("resolves the root only after the preview layout delay", () => { + vi.useFakeTimers(); + const getRoot = vi.fn(() => undefined); + scheduleScrollToFirstChange(getRoot); + expect(getRoot).not.toHaveBeenCalled(); + vi.advanceTimersByTime(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(getRoot).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("skips a superseded preview without resolving its root", () => { + vi.useFakeTimers(); + const getRoot = vi.fn(() => undefined); + let current = true; + scheduleScrollToFirstChange(getRoot, { isCurrent: () => current }); + current = false; + vi.advanceTimersByTime(SCROLL_TO_FIRST_CHANGE_DELAY_MS); + expect(getRoot).not.toHaveBeenCalled(); +}); + +it("does not schedule disabled scrolling", () => { + vi.useFakeTimers(); + const getRoot = vi.fn(() => undefined); + scheduleScrollToFirstChange(getRoot, { enabled: false }); + expect(vi.getTimerCount()).toBe(0); + expect(getRoot).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts new file mode 100644 index 0000000000..2a0a650a3a --- /dev/null +++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts @@ -0,0 +1,320 @@ +/** + * @vitest-environment jsdom + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import { scrollToFirstChange } from "./scrollToFirstChange.js"; + +// jsdom implements neither `scrollIntoView` nor layout, so both are installed +// here: `scrollIntoView` to observe the call, `getBoundingClientRect` per +// element to model which nodes have a box. +const originalAnimate = Object.getOwnPropertyDescriptor( + Element.prototype, + "animate", +); +const animate = vi.fn( + ( + _frames: Keyframe[] | PropertyIndexedKeyframes | null, + _options?: number | KeyframeAnimationOptions, + ): { cancel: ReturnType; onfinish?: () => void } => ({ + cancel: vi.fn(), + }), +); +const hadScrollIntoView = "scrollIntoView" in Element.prototype; +let scrollIntoView: ReturnType>; + +/** Give `element` a non-empty layout box. */ +function withBox(element: Element): Element { + element.getBoundingClientRect = () => ({ width: 100, height: 20 }) as DOMRect; + return element; +} + +/** Give `element` a zero-sized box, as `display: contents` wrappers have. */ +function withoutBox(element: Element): Element { + element.getBoundingClientRect = () => ({ width: 0, height: 0 }) as DOMRect; + return element; +} + +function makeRoot(): HTMLElement { + const root = document.createElement("div"); + document.body.appendChild(root); + return root; +} + +beforeEach(() => { + animate.mockClear(); + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: animate, + }); + scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; +}); + +afterEach(() => { + document.body.innerHTML = ""; + if (originalAnimate) { + Object.defineProperty(Element.prototype, "animate", originalAnimate); + } else { + Reflect.deleteProperty(Element.prototype, "animate"); + } + if (!hadScrollIntoView) { + Reflect.deleteProperty(Element.prototype, "scrollIntoView"); + } +}); + +describe("scrollToFirstChange", () => { + it("returns false when there is no root", () => { + expect(scrollToFirstChange(undefined)).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("returns false when the document has no attribution marks", () => { + expect(scrollToFirstChange(makeRoot())).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("scrolls to the content element of the first mark", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withBox(document.createElement("span")); + wrapper.appendChild(content); + root.appendChild(wrapper); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView.mock.instances[0]).toBe(content); + }); + + it("descends one level further when the content element has no box", () => { + const root = makeRoot(); + const wrapper = document.createElement("div"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withoutBox(document.createElement("div")); + const inner = withBox(document.createElement("p")); + content.appendChild(inner); + wrapper.appendChild(content); + root.appendChild(wrapper); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(inner); + }); + + it("picks the first mark in document order", () => { + const root = makeRoot(); + for (const id of ["u1", "u2"]) { + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = `["${id}"]`; + wrapper.appendChild(withBox(document.createElement("span"))); + root.appendChild(wrapper); + } + + scrollToFirstChange(root); + + expect(scrollIntoView.mock.instances[0]).toBe( + root.firstElementChild!.firstElementChild, + ); + }); + + it("scrolls smoothly by default and instantly under reduced motion", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(withBox(document.createElement("span"))); + root.appendChild(wrapper); + + // jsdom has no `matchMedia`; the helper optional-calls it, so the + // no-preference default is exercised by simply leaving it out. + scrollToFirstChange(root); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: "center", + behavior: "smooth", + }); + + window.matchMedia = vi.fn(() => ({ matches: true }) as MediaQueryList); + scrollToFirstChange(root); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: "center", + behavior: "auto", + }); + + const frames = animate.mock.calls.at(-1)?.[0]; + expect(frames).toEqual([ + expect.not.objectContaining({ transform: expect.anything() }), + expect.not.objectContaining({ transform: expect.anything() }), + ]); + + Reflect.deleteProperty(window, "matchMedia"); + }); + + it("highlights without DOM mutations and releases the finished animation", () => { + const root = makeRoot(); + const block = document.createElement("div"); + block.className = "bn-block-content"; + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(withBox(document.createElement("span"))); + block.appendChild(wrapper); + root.appendChild(block); + + const observer = new MutationObserver(() => {}); + observer.observe(root, { + attributes: true, + childList: true, + subtree: true, + }); + scrollToFirstChange(root); + expect(observer.takeRecords()).toEqual([]); + observer.disconnect(); + // The whole block, not the mark that was scrolled to. + expect(animate.mock.instances[0]).toBe(block); + expect(block.className).toBe("bn-block-content"); + expect(block.hasAttribute("style")).toBe(false); + expect(animate).toHaveBeenCalledWith(expect.any(Array), { + duration: 1500, + fill: "none", + }); + + const animation = animate.mock.results[0]!.value; + scrollToFirstChange(root); + // Fire-and-forget highlight: the finished animation is cancelled by the + // next scroll instead of released via `onfinish`. + expect(animation.cancel).toHaveBeenCalledOnce(); + }); + + it("highlights the scrolled-to element when it is in no block", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withBox(document.createElement("span")); + wrapper.appendChild(content); + root.appendChild(wrapper); + + scrollToFirstChange(root); + expect(animate.mock.instances.at(-1)).toBe(content); + }); + + /** A mark wrapper of the given element type around a content span. */ + function makeMark(tag: "ins" | "del" | "span", content: Element): Element { + const wrapper = document.createElement(tag); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(content); + return wrapper; + } + + it("skips marks with no layout box, such as inside a collapsed toggle", () => { + const root = makeRoot(); + // A block-level mark whose whole subtree is hidden: nothing below it has a + // box, so descending would never find one. + const hiddenContent = withoutBox(document.createElement("span")); + hiddenContent.appendChild(withoutBox(document.createElement("div"))); + root.appendChild(makeMark("ins", hiddenContent)); + const visible = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", visible)); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(visible); + }); + + it("falls back to the containing block when every mark is hidden", () => { + const root = makeRoot(); + // A collapsed toggle: its content is laid out, its child group is not. + const outer = withBox(document.createElement("div")); + outer.className = "bn-block-outer"; + const block = withBox(document.createElement("div")); + block.className = "bn-block"; + const toggleContent = withBox(document.createElement("div")); + toggleContent.className = "bn-block-content"; + const hiddenGroup = withoutBox(document.createElement("div")); + hiddenGroup.className = "bn-block-group"; + hiddenGroup.appendChild( + makeMark("ins", withoutBox(document.createElement("span"))), + ); + block.append(toggleContent, hiddenGroup); + outer.appendChild(block); + root.appendChild(outer); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(toggleContent); + expect(animate.mock.instances.at(-1)).toBe(toggleContent); + }); + + it("returns false when a hidden mark has no laid-out ancestor below the root", () => { + const root = makeRoot(); + root.appendChild( + makeMark("ins", withoutBox(document.createElement("span"))), + ); + + expect(scrollToFirstChange(root)).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("scrolls to the first change in document order, regardless of kind", () => { + const root = makeRoot(); + root.appendChild(makeMark("del", withBox(document.createElement("span")))); + const formatted = withBox(document.createElement("span")); + root.appendChild(makeMark("span", formatted)); + + scrollToFirstChange(root); + expect(scrollIntoView.mock.instances[0]).toBe( + root.firstElementChild!.firstElementChild, + ); + + root.removeChild(root.firstElementChild!); + scrollToFirstChange(root); + expect(scrollIntoView.mock.instances[1]).toBe(formatted); + }); + + it("scrolls to and highlights the block's own content for a block-level mark", () => { + const root = makeRoot(); + // `` > content span (display: contents) > .bn-block-outer > .bn-block > + // .bn-block-content, with a nested child block group after the content. + const content = withoutBox(document.createElement("span")); + const outer = withBox(document.createElement("div")); + outer.className = "bn-block-outer"; + const block = withBox(document.createElement("div")); + block.className = "bn-block"; + const blockContent = withBox(document.createElement("div")); + blockContent.className = "bn-block-content"; + const childContent = withBox(document.createElement("div")); + childContent.className = "bn-block-content"; + block.append(blockContent, childContent); + outer.appendChild(block); + content.appendChild(outer); + root.appendChild(makeMark("ins", content)); + + scrollToFirstChange(root); + + expect(scrollIntoView.mock.instances[0]).toBe(blockContent); + expect(animate.mock.instances.at(-1)).toBe(blockContent); + expect(animate).toHaveBeenCalledTimes(1); + }); + + it("moves the highlight when a new preview scrolls elsewhere", () => { + const root = makeRoot(); + const first = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", first)); + scrollToFirstChange(root); + expect(animate.mock.instances.at(-1)).toBe(first); + + root.replaceChildren(); + const second = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", second)); + scrollToFirstChange(root); + + expect(animate.mock.results[0]!.value.cancel).toHaveBeenCalledOnce(); + expect(animate.mock.instances.at(-1)).toBe(second); + // A late finish event from the cancelled pulse must not clear its successor. + animate.mock.results[0]!.value.onfinish?.(); + scrollToFirstChange(root); + expect(animate.mock.results[1]!.value.cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts new file mode 100644 index 0000000000..660ac272c5 --- /dev/null +++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts @@ -0,0 +1,139 @@ +// Attribution wrappers carry `data-user-ids` but may be `display: contents`. +// Resolve their layout boxes locally without importing the `@y/*` stack. + +/** Duration of the transient block highlight. */ +const HIGHLIGHT_MS = 1500; + +/** Allow the preview layout to settle; animation frames pause in background tabs. */ +export const SCROLL_TO_FIRST_CHANGE_DELAY_MS = 200; + +function hasBox(element: Element): boolean { + const { width, height } = element.getBoundingClientRect(); + return width !== 0 || height !== 0; +} + +/** Descend through `display: contents` wrappers to the first laid-out node. */ +function findVisibleTarget(mark: Element): Element | undefined { + for ( + let element: Element | null = mark; + element; + element = element.firstElementChild + ) { + if (hasBox(element)) { + return element; + } + } + return undefined; +} + +/** + * Nearest ancestor of `mark` (below `root`) with a layout box: for a change + * hidden inside a collapsed toggle, that's the toggle block itself. + */ +function findVisibleAncestor( + mark: Element, + root: Element, +): Element | undefined { + for ( + let element = mark.parentElement; + element && element !== root && root.contains(element); + element = element.parentElement + ) { + if (hasBox(element)) { + return element; + } + } + return undefined; +} + +/** Cancel the previous highlight when another change is revealed. */ +let activeHighlight: Animation | undefined; + +function highlight(block: Element) { + activeHighlight?.cancel(); + // Fire-and-forget pulse: no DOM mutations for ProseMirror to observe. A + // finished animation stays referenced until the next scroll cancels it, + // which is a harmless no-op. Scrolling still works without WAAPI. + activeHighlight = block.animate?.( + [ + { + backgroundColor: "color-mix(in srgb, #3e5de7 14%, transparent)", + boxShadow: "0 0 0 1px color-mix(in srgb, #3e5de7 45%, transparent)", + borderRadius: "4px", + easing: "ease-out", + }, + { + backgroundColor: "transparent", + boxShadow: "0 0 0 1px transparent", + borderRadius: "4px", + }, + ], + { duration: HIGHLIGHT_MS, fill: "none" }, + ); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false) + ); +} + +/** + * Scroll to the first change after preview layout settles. No-ops when + * disabled or when `isCurrent` reports the preview as superseded. + * Returns a cancellation callback when a timer is scheduled. + */ +export function scheduleScrollToFirstChange( + getRoot: () => Element | undefined, + options?: { enabled?: boolean; isCurrent?: () => boolean }, +): (() => void) | undefined { + if (options?.enabled === false) { + return; + } + // Let preview layout settle; timers also run in background tabs. + const timeout = setTimeout(() => { + if (options?.isCurrent && !options.isCurrent()) { + return; + } + scrollToFirstChange(getRoot()); + }, SCROLL_TO_FIRST_CHANGE_DELAY_MS); + return () => clearTimeout(timeout); +} + +/** + * Centre the first change in document order and highlight its block, + * respecting reduced motion. Changes hidden inside collapsed content fall + * back to their toggle block. + * @returns Whether a change was found and scrolled to. + */ +export function scrollToFirstChange(root: Element | undefined): boolean { + if (!root) { + return false; + } + + let firstMark: Element | undefined; + let target: Element | undefined; + for (const mark of root.querySelectorAll("[data-user-ids]")) { + firstMark ??= mark; + target = findVisibleTarget(mark); + if (target) { + break; + } + } + target ??= firstMark ? findVisibleAncestor(firstMark, root) : undefined; + if (!target) { + return false; + } + // A block-level mark wraps the block; point at its content instead so the + // highlight doesn't span nested children. + target = target.querySelector(".bn-block-content") ?? target; + + target.scrollIntoView({ + block: "center", + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + highlight(target.closest(".bn-block-content") ?? target); + + return true; +} diff --git a/packages/core/src/extensions/Versioning/types.ts b/packages/core/src/extensions/Versioning/types.ts new file mode 100644 index 0000000000..1dab09a917 --- /dev/null +++ b/packages/core/src/extensions/Versioning/types.ts @@ -0,0 +1,219 @@ +/** + * Metadata for a stored document version returned by {@link VersionStorage.list}. + * Its content is loaded separately through {@link VersionStorage.getContent}. + * The frozen current document belongs to {@link VersionView.current}, not this list. + */ +export interface VersionSnapshot { + /** Storage identifier used by {@link VersionSelection} and {@link VersionStorage}. */ + id: string; + /** Version timestamp in milliseconds since the Unix epoch. */ + createdAt: number; + /** Optional user-assigned name, changed through {@link VersionStorage.rename}. */ + name?: string; + /** Author identifier or identifiers, resolved to users by the sidebar's user store. */ + by?: string | string[]; + /** Optional additional text displayed beneath the version's name or timestamp. */ + secondaryLabel?: string; + /** Original version whose content produced this version through a restore. */ + restoredFrom?: { id: string; createdAt: number }; + /** Backend-specific metadata, opaque to the versioning controller. */ + metadata?: unknown; + /** Backend-provided attribution labels associated with this version. */ + customAttributions?: Record; +} + +/** One newest-first page. Identifiers and query options stay stable across pages. */ +export interface VersionSnapshotPage { + snapshots: VersionSnapshot[]; + /** Opaque continuation; absent when history is exhausted. */ + nextCursor?: string; +} + +/** + * Document to display in an open {@link VersionView}. + * `current` selects the capture made at opening, not the latest live document. + * `snapshot` selects a stored {@link VersionSnapshot} by its identifier. + */ +export type VersionSelection = + | { type: "current" } + | { type: "snapshot"; id: string }; + +/** + * Resolved input to {@link VersionView.show}; all asynchronous loading has finished. + * Content and attribution formats are defined by the matching {@link VersionStorage} + * and {@link VersionViewAdapter}. + */ +export interface VersionDisplay { + /** Target document content to render. */ + content: Content; + /** Older baseline and optional change attributions for a comparison preview. */ + comparison?: { content: Content; attributions?: Attributions }; + /** Selection represented by `content`, used for rendering labels and context. */ + target: VersionSelection; +} + +/** + * One temporary document view acquired through {@link VersionViewAdapter.open}. + * Owns document binding, cursor suppression, and undo isolation while the live + * collaborative document continues synchronizing separately. + */ +export interface VersionView { + /** + * Detached content captured at opening, stable until {@link VersionView.close}. + * `capturedAt` is Unix time in milliseconds, also used as the current-document + * attribution cutoff by {@link VersionStorage.getAttributions}. + */ + readonly current: { content: Content; capturedAt: number }; + /** + * Synchronously render a resolved {@link VersionDisplay} without reconnecting + * live synchronization or publishing preview cursors. Never called after close. + */ + show(display: VersionDisplay): void; + /** + * Discard the temporary view and restore live bindings and undo state. + * Safe to call repeatedly. Does not apply a {@link VersionStorage.restore}. + */ + close(): void; +} + +/** + * Backend-specific owner of isolated {@link VersionView} instances. + * Paired with a {@link VersionStorage} using the same content and attribution formats. + */ +export interface VersionViewAdapter { + /** Whether {@link VersionView.show} supports {@link VersionDisplay.comparison}. */ + readonly supportsComparison: boolean; + /** + * Immediately freeze the displayed document and acquire its isolated view. + * If acquisition throws, the adapter must undo any partially acquired bindings. + */ + open(): VersionView; +} + +/** + * Safe classifications for expected provider failures. An unknown timeout outcome + * must be reconciled with the server before retrying a non-idempotent mutation. + */ +export type VersionError = + | { type: "network" } + | { type: "timeout"; outcome: "unknown" | "unchanged" } + | { type: "forbidden" } + | { type: "conflict" } + | { type: "not-found" } + | { type: "server"; status: number }; + +/** Expected failures are values. Unexpected bugs still reject. */ +export type VersionResult = + | { ok: true; value: T } + | { ok: false; error: VersionError }; + +/** Existing data remains visible during refresh and after a failed refresh. */ +export type VersionQueryState = + | { status: "pending"; data?: T; error?: never } + | { status: "success"; data: T; error?: never } + | { status: "error"; data?: T; error: VersionError }; + +/** + * Backend operations for stored version metadata and content. Expected failures + * return results; unexpected bugs throw. Mutations continue when the view closes. + */ +export interface VersionStorage { + /** + * Prepend a separate frozen Current version. Defaults to true. Set false when + * the newest listed checkpoint represents Current; opening loads that checkpoint + * instead of the local capture, without comparing their content. + */ + readonly showCurrentVersion?: boolean; + /** The list includes the first available recorded version, even when otherwise limited. */ + readonly historyIncludesBeginning?: boolean; + /** Load the first page, or continue with the opaque cursor from the previous page. */ + list( + signal: AbortSignal, + cursor?: string, + ): Promise>; + /** Load a stored version's content for {@link VersionView.show}. */ + getContent(id: string, signal: AbortSignal): Promise>; + /** + * Load change attribution data between `baselineId` and `target` for + * {@link VersionDisplay.comparison}. For a current target, `capturedAt` is the + * capture timestamp from {@link VersionView.current}, not a history-row timestamp. + */ + getAttributions?: ( + target: VersionSelection, + baselineId: string, + capturedAt: number, + signal: AbortSignal, + ) => Promise>; + /** + * Name current. Snapshot stores save `content` from {@link VersionView.current}; + * continuous-history stores must identify the captured checkpoint, not a later one. + * Success returns checkpoint metadata. No checkpoint to name is a typed failure. + */ + create?: ( + content: Content, + name?: string, + capturedAt?: number, + ) => Promise>; + /** + * Restore a stored version to the live document, including backend-specific + * application. Success means the live document has received the restore, not + * just that the server accepted it. The controller discards pre-restore views; + * opening during restore is unavailable. Reopen after completion to capture + * restored Current. + */ + restore?: (id: string) => Promise>; + /** Change a stored version's name; `undefined` clears it. Refresh {@link VersionStorage.list} afterward. */ + rename?: (id: string, name?: string) => Promise>; + /** + * Remove a stored version, or only its name on continuous-history backends. + * The controller refreshes {@link VersionStorage.list} and reconciles selection. + */ + remove?: (id: string) => Promise>; +} + +/** + * Published state of versioning, distinct from the independently syncing live document. + * `live` means no temporary {@link VersionView} is owned; `versions` describes + * the frozen preview and its asynchronous reads. + */ +export type VersioningState = + | { mode: "live"; restoring?: boolean } + | { + mode: "versions"; + /** Capture time from {@link VersionView.current}, in Unix milliseconds. */ + capturedAt: number; + /** Whether Current is a separate local capture. Defaults to true. */ + showCurrentVersion?: boolean; + /** Last successfully rendered {@link VersionSelection}. */ + displayed: VersionSelection; + /** Stored baseline identifier used by the displayed comparison, if any. */ + compareTo?: string; + /** Requested selection still loading; {@link VersioningState.displayed} remains visible. */ + pending?: VersionSelection; + /** + * History loading status. Previously loaded {@link VersionSnapshot} rows + * remain available during refresh or failure; errors contain safe classifications. + */ + history: VersionQueryState & + ( + | { status: "pending"; operation?: "refresh" | "loadMore" } + | { status: "success"; operation?: never } + | { status: "error"; operation: "refresh" | "loadMore" } + ); + /** Continuation for the loaded history, retained when a request fails. */ + nextCursor?: string; + /** A live restore is in progress; editing and new selections remain blocked. */ + restoring: boolean; + }; + +/** + * Outcome of a controller read, selection, or mutation. + * `done` means it completed; `cancelled` means its request/view was superseded; + * `unavailable` means the action cannot run in the current state or is unsupported + * by {@link VersionStorage}. Unexpected failures reject rather than returning this type. + */ +export type VersionOperationResult = + | { status: "done" } + | { status: "error"; error: VersionError } + | { status: "cancelled" } + | { status: "unavailable" }; diff --git a/packages/core/src/extensions/Versioning/versioningState.ts b/packages/core/src/extensions/Versioning/versioningState.ts new file mode 100644 index 0000000000..036f7010da --- /dev/null +++ b/packages/core/src/extensions/Versioning/versioningState.ts @@ -0,0 +1,154 @@ +import type { + VersionError, + VersioningState, + VersionSelection, + VersionSnapshot, +} from "./types.js"; + +export type VersioningEvent = + | { + type: "opened"; + capturedAt: number; + showCurrentVersion: boolean; + restoring: boolean; + } + | { type: "closed"; restoring?: boolean } + | { type: "historyStarted"; operation: "refresh" | "loadMore" } + | { + type: "historyFailed"; + operation: "refresh" | "loadMore"; + error: VersionError; + } + | { + type: "historyLoaded"; + operation: "refresh" | "loadMore"; + snapshots: VersionSnapshot[]; + nextCursor?: string; + } + | { type: "selectionStarted"; target: VersionSelection } + | { type: "selectionFailed" } + | { type: "selectionShown"; target: VersionSelection; compareTo?: string } + | { type: "restoreChanged"; restoring: boolean } + | { type: "snapshotRenamed"; id: string; name?: string } + | { type: "snapshotCreated"; snapshot: VersionSnapshot }; + +function mergeSnapshots(snapshots: VersionSnapshot[]) { + return [ + ...new Map(snapshots.map((snapshot) => [snapshot.id, snapshot])).values(), + ].sort((a, b) => b.createdAt - a.createdAt); +} + +/** Pure published-state transitions. Request ownership and editor effects stay in the controller. */ +export function reduceVersioningState( + state: VersioningState, + event: VersioningEvent, +): VersioningState { + if (event.type === "opened") { + return { + mode: "versions", + capturedAt: event.capturedAt, + showCurrentVersion: event.showCurrentVersion, + restoring: event.restoring, + displayed: { type: "current" }, + history: { status: "pending" }, + }; + } + if (event.type === "closed") { + return event.restoring + ? { mode: "live", restoring: true } + : { mode: "live" }; + } + if (state.mode === "live") { + if (event.type === "restoreChanged") { + return event.restoring + ? { mode: "live", restoring: true } + : { mode: "live" }; + } + return state; + } + switch (event.type) { + case "historyStarted": + return { + ...state, + mode: "versions", + history: { + status: "pending", + operation: event.operation, + data: state.history.data, + }, + }; + case "historyFailed": + return { + ...state, + mode: "versions", + history: { + status: "error", + operation: event.operation, + error: event.error, + data: state.history.data, + }, + }; + case "historyLoaded": + return { + ...state, + mode: "versions", + nextCursor: event.nextCursor, + history: { + status: "success", + data: mergeSnapshots( + event.operation === "refresh" + ? event.snapshots + : [...(state.history.data ?? []), ...event.snapshots], + ), + }, + }; + case "selectionStarted": + return { ...state, pending: event.target }; + case "selectionFailed": + return { ...state, pending: undefined }; + case "selectionShown": + return { + ...state, + displayed: event.target, + compareTo: event.compareTo, + pending: undefined, + }; + case "restoreChanged": + return { + ...state, + restoring: event.restoring, + pending: event.restoring ? undefined : state.pending, + }; + case "snapshotRenamed": + return { + ...state, + mode: "versions", + history: { + status: "success", + data: (state.history.data ?? []).map((snapshot) => + snapshot.id === event.id + ? { ...snapshot, name: event.name } + : snapshot, + ), + }, + }; + case "snapshotCreated": + return { + ...state, + mode: "versions", + history: { + status: "success", + data: mergeSnapshots([ + event.snapshot, + ...(state.history.data ?? []).filter( + (snapshot) => snapshot.id !== event.snapshot.id, + ), + ]), + }, + }; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +} diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index eb1d455e33..c9ed09e7b4 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -10,6 +10,7 @@ export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; export * from "./PositionMapping/PositionMapping.js"; export * from "./PreviousBlockType/PreviousBlockType.js"; +export * from "./ReadOnly/ReadOnly.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js"; @@ -22,4 +23,5 @@ export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; export * from "./SuggestionMenu/SuggestionMenu.js"; export * from "./TableHandles/TableHandles.js"; export * from "./TrailingNode/TrailingNode.js"; +export * from "./tiptap-extensions/UniqueID/UniqueID.js"; export * from "./Versioning/index.js"; diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 1c19b810dd..9bc6ba0de7 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -394,17 +394,48 @@ export const ar: Dictionary = { suggestion_changes: { formatting_change: "تغيير التنسيق", deleted: "محذوف", + inserted: "مُدرج", inserted_by: (users: string) => `أُدرج بواسطة: ${users}`, deleted_by: (users: string) => `حُذف بواسطة: ${users}`, + changed: "مُعدَّل", + changed_by: (users: string) => `عُدّل بواسطة: ${users}`, formatting_change_by: (formats: string, users: string) => `تغيير التنسيق (${formats}) بواسطة: ${users}`, }, + versioning: { + start_of_document: "بداية المستند", + compare_since_beginning_menuitem: "المقارنة منذ البداية", + title: "السجل", + close: "إغلاق", + show_named_only: "إظهار الإصدارات المسماة فقط", + show_all: "إظهار جميع الإصدارات", + comparison_on: "تفعيل المقارنة", + comparison_off: "إيقاف المقارنة", + versions_list: "الإصدارات", + empty: "لا توجد إصدارات بعد", + empty_named_only: "لا توجد إصدارات مسماة", + current_version: "الإصدار الحالي", + before_restore: "قبل الاستعادة", + comparing_to: "مقارنة بـ", + restored_from: (date: string) => `تمت الاستعادة من ${date}`, + more_actions: "إجراءات أخرى", + version_name_input: "اسم الإصدار", + name_version_menuitem: "تسمية هذا الإصدار", + rename_menuitem: "إعادة تسمية", + compare_with_menuitem: "المقارنة بهذا الإصدار", + restore_menuitem: "استعادة", + delete_menuitem: "حذف", + action_failed: "حدث خطأ ما. يرجى المحاولة مرة أخرى.", + history_load_failed: "تعذر تحميل سجل الإصدارات", + }, exporter: { open_file: "فتح الملف", open_video_file: "فتح الفيديو", open_audio_file: "فتح الصوت", }, generic: { + loading: "جارٍ التحميل...", + load_more: "تحميل المزيد", ctrl_shortcut: "Ctrl", form_submit: "موافق", }, diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 45ff9341d8..537bc5acd1 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -428,17 +428,48 @@ export const de: Dictionary = { suggestion_changes: { formatting_change: "Formatierungsänderung", deleted: "Gelöscht", + inserted: "Eingefügt", inserted_by: (users: string) => `Eingefügt von: ${users}`, deleted_by: (users: string) => `Gelöscht von: ${users}`, + changed: "Geändert", + changed_by: (users: string) => `Geändert von: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatierungsänderung (${formats}) von: ${users}`, }, + versioning: { + start_of_document: "Anfang des Dokuments", + compare_since_beginning_menuitem: "Seit Beginn vergleichen", + title: "Verlauf", + close: "Schließen", + show_named_only: "Nur benannte Versionen anzeigen", + show_all: "Alle Versionen anzeigen", + comparison_on: "Vergleich einschalten", + comparison_off: "Vergleich ausschalten", + versions_list: "Versionen", + empty: "Noch keine Versionen", + empty_named_only: "Keine benannten Versionen", + current_version: "Aktuelle Version", + before_restore: "Vor der Wiederherstellung", + comparing_to: "Verglichen mit", + restored_from: (date: string) => `Wiederhergestellt aus ${date}`, + more_actions: "Weitere Aktionen", + version_name_input: "Versionsname", + name_version_menuitem: "Diese Version benennen", + rename_menuitem: "Umbenennen", + compare_with_menuitem: "Mit dieser Version vergleichen", + restore_menuitem: "Wiederherstellen", + delete_menuitem: "Löschen", + action_failed: "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + history_load_failed: "Versionsverlauf konnte nicht geladen werden", + }, exporter: { open_file: "Datei öffnen", open_video_file: "Video öffnen", open_audio_file: "Audio öffnen", }, generic: { + loading: "Wird geladen...", + load_more: "Mehr laden", ctrl_shortcut: "Strg", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 307ba90c22..c87731fcf1 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -409,17 +409,48 @@ export const en = { suggestion_changes: { formatting_change: "Formatting Change", deleted: "Deleted", + inserted: "Inserted", inserted_by: (users: string) => `Inserted by: ${users}`, deleted_by: (users: string) => `Deleted by: ${users}`, + changed: "Changed", + changed_by: (users: string) => `Changed by: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatting change (${formats}) by: ${users}`, }, + versioning: { + start_of_document: "Start of document", + compare_since_beginning_menuitem: "Compare since beginning", + title: "History", + close: "Close", + show_named_only: "Show named versions only", + show_all: "Show all versions", + comparison_on: "Turn on comparison", + comparison_off: "Turn off comparison", + versions_list: "Versions", + empty: "No versions yet", + empty_named_only: "No named versions", + current_version: "Current version", + before_restore: "Before restore", + comparing_to: "Comparing to", + restored_from: (date: string) => `Restored from ${date}`, + more_actions: "More actions", + version_name_input: "Version name", + name_version_menuitem: "Name this version", + rename_menuitem: "Rename", + compare_with_menuitem: "Compare with this version", + restore_menuitem: "Restore", + delete_menuitem: "Delete", + action_failed: "Something went wrong. Please try again.", + history_load_failed: "Failed to load version history", + }, exporter: { open_file: "Open file", open_video_file: "Open video", open_audio_file: "Open audio", }, generic: { + loading: "Loading...", + load_more: "Load more", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index b2c05ca6b2..96fa83e013 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -407,17 +407,48 @@ export const es: Dictionary = { suggestion_changes: { formatting_change: "Cambio de formato", deleted: "Eliminado", + inserted: "Insertado", inserted_by: (users: string) => `Insertado por: ${users}`, deleted_by: (users: string) => `Eliminado por: ${users}`, + changed: "Modificado", + changed_by: (users: string) => `Modificado por: ${users}`, formatting_change_by: (formats: string, users: string) => `Cambio de formato (${formats}) por: ${users}`, }, + versioning: { + start_of_document: "Inicio del documento", + compare_since_beginning_menuitem: "Comparar desde el inicio", + title: "Historial", + close: "Cerrar", + show_named_only: "Mostrar solo versiones con nombre", + show_all: "Mostrar todas las versiones", + comparison_on: "Activar comparación", + comparison_off: "Desactivar comparación", + versions_list: "Versiones", + empty: "Aún no hay versiones", + empty_named_only: "No hay versiones con nombre", + current_version: "Versión actual", + before_restore: "Antes de restaurar", + comparing_to: "Comparando con", + restored_from: (date: string) => `Restaurado desde ${date}`, + more_actions: "Más acciones", + version_name_input: "Nombre de la versión", + name_version_menuitem: "Nombrar esta versión", + rename_menuitem: "Cambiar nombre", + compare_with_menuitem: "Comparar con esta versión", + restore_menuitem: "Restaurar", + delete_menuitem: "Eliminar", + action_failed: "Algo salió mal. Inténtalo de nuevo.", + history_load_failed: "No se pudo cargar el historial de versiones", + }, exporter: { open_file: "Abrir archivo", open_video_file: "Abrir vídeo", open_audio_file: "Abrir audio", }, generic: { + loading: "Cargando...", + load_more: "Cargar más", ctrl_shortcut: "Ctrl", form_submit: "Aceptar", }, diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 405cf87ddf..35b907ac13 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -378,17 +378,48 @@ export const fa = { suggestion_changes: { formatting_change: "تغییر قالب‌بندی", deleted: "حذف\u200cشده", + inserted: "درج\u200cشده", inserted_by: (users: string) => `درج‌شده توسط: ${users}`, deleted_by: (users: string) => `حذف‌شده توسط: ${users}`, + changed: "تغییر\u200cیافته", + changed_by: (users: string) => `تغییر\u200cیافته توسط: ${users}`, formatting_change_by: (formats: string, users: string) => `تغییر قالب‌بندی (${formats}) توسط: ${users}`, }, + versioning: { + start_of_document: "آغاز سند", + compare_since_beginning_menuitem: "مقایسه از ابتدا", + title: "تاریخچه", + close: "بستن", + show_named_only: "فقط نسخه‌های نام‌گذاری‌شده نمایش داده شود", + show_all: "نمایش همه نسخه‌ها", + comparison_on: "روشن کردن مقایسه", + comparison_off: "خاموش کردن مقایسه", + versions_list: "نسخه‌ها", + empty: "هنوز نسخه‌ای وجود ندارد", + empty_named_only: "نسخه‌ای با نام وجود ندارد", + current_version: "نسخه فعلی", + before_restore: "پیش از بازیابی", + comparing_to: "مقایسه با", + restored_from: (date: string) => `بازیابی‌شده از ${date}`, + more_actions: "اقدامات بیشتر", + version_name_input: "نام نسخه", + name_version_menuitem: "نام‌گذاری این نسخه", + rename_menuitem: "تغییر نام", + compare_with_menuitem: "مقایسه با این نسخه", + restore_menuitem: "بازیابی", + delete_menuitem: "حذف", + action_failed: "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", + history_load_failed: "بارگیری تاریخچه نسخه‌ها ناموفق بود", + }, exporter: { open_file: "باز کردن فایل", open_video_file: "باز کردن ویدیو", open_audio_file: "باز کردن صدا", }, generic: { + loading: "در حال بارگیری...", + load_more: "بارگیری بیشتر", ctrl_shortcut: "Ctrl", form_submit: "تأیید", }, diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 4807927655..653fd9581b 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -455,17 +455,48 @@ export const fr: Dictionary = { suggestion_changes: { formatting_change: "Modification de mise en forme", deleted: "Supprimé", + inserted: "Inséré", inserted_by: (users: string) => `Inséré par : ${users}`, deleted_by: (users: string) => `Supprimé par : ${users}`, + changed: "Modifié", + changed_by: (users: string) => `Modifié par : ${users}`, formatting_change_by: (formats: string, users: string) => `Modification de mise en forme (${formats}) par : ${users}`, }, + versioning: { + start_of_document: "Début du document", + compare_since_beginning_menuitem: "Comparer depuis le début", + title: "Historique", + close: "Fermer", + show_named_only: "Afficher uniquement les versions nommées", + show_all: "Afficher toutes les versions", + comparison_on: "Activer la comparaison", + comparison_off: "Désactiver la comparaison", + versions_list: "Versions", + empty: "Aucune version pour l'instant", + empty_named_only: "Aucune version nommée", + current_version: "Version actuelle", + before_restore: "Avant la restauration", + comparing_to: "Comparé à", + restored_from: (date: string) => `Restauré depuis ${date}`, + more_actions: "Plus d'actions", + version_name_input: "Nom de la version", + name_version_menuitem: "Nommer cette version", + rename_menuitem: "Renommer", + compare_with_menuitem: "Comparer avec cette version", + restore_menuitem: "Restaurer", + delete_menuitem: "Supprimer", + action_failed: "Une erreur s'est produite. Veuillez réessayer.", + history_load_failed: "Impossible de charger l'historique des versions", + }, exporter: { open_file: "Ouvrir le fichier", open_video_file: "Ouvrir la vidéo", open_audio_file: "Ouvrir l'audio", }, generic: { + loading: "Chargement...", + load_more: "Charger plus", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 1b9338b77b..0e51f20987 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -409,17 +409,48 @@ export const he: Dictionary = { suggestion_changes: { formatting_change: "שינוי עיצוב", deleted: "נמחק", + inserted: "נוסף", inserted_by: (users: string) => `נוסף על ידי: ${users}`, deleted_by: (users: string) => `נמחק על ידי: ${users}`, + changed: "שונה", + changed_by: (users: string) => `שונה על ידי: ${users}`, formatting_change_by: (formats: string, users: string) => `שינוי עיצוב (${formats}) על ידי: ${users}`, }, + versioning: { + start_of_document: "תחילת המסמך", + compare_since_beginning_menuitem: "השוואה מההתחלה", + title: "היסטוריה", + close: "סגירה", + show_named_only: "הצג גרסאות בעלות שם בלבד", + show_all: "הצג את כל הגרסאות", + comparison_on: "הפעלת השוואה", + comparison_off: "כיבוי השוואה", + versions_list: "גרסאות", + empty: "אין עדיין גרסאות", + empty_named_only: "אין גרסאות עם שם", + current_version: "גרסה נוכחית", + before_restore: "לפני השחזור", + comparing_to: "משווה מול", + restored_from: (date: string) => `שוחזר מ-${date}`, + more_actions: "פעולות נוספות", + version_name_input: "שם הגרסה", + name_version_menuitem: "מתן שם לגרסה זו", + rename_menuitem: "שינוי שם", + compare_with_menuitem: "השוואה לגרסה זו", + restore_menuitem: "שחזור", + delete_menuitem: "מחיקה", + action_failed: "משהו השתבש. נסו שוב.", + history_load_failed: "טעינת היסטוריית הגרסאות נכשלה", + }, exporter: { open_file: "פתח קובץ", open_video_file: "פתח וידאו", open_audio_file: "פתח שמע", }, generic: { + loading: "טוען...", + load_more: "טען עוד", ctrl_shortcut: "Ctrl", form_submit: "אישור", }, diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 998a245f20..e7e1dc1425 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -423,17 +423,48 @@ export const hr: Dictionary = { suggestion_changes: { formatting_change: "Promjena oblikovanja", deleted: "Izbrisano", + inserted: "Umetnuto", inserted_by: (users: string) => `Umetnuo/la: ${users}`, deleted_by: (users: string) => `Izbrisao/la: ${users}`, + changed: "Promijenjeno", + changed_by: (users: string) => `Promijenio/la: ${users}`, formatting_change_by: (formats: string, users: string) => `Promjena oblikovanja (${formats}) od: ${users}`, }, + versioning: { + start_of_document: "Početak dokumenta", + compare_since_beginning_menuitem: "Usporedi od početka", + title: "Povijest", + close: "Zatvori", + show_named_only: "Prikaži samo imenovane verzije", + show_all: "Prikaži sve verzije", + comparison_on: "Uključi usporedbu", + comparison_off: "Isključi usporedbu", + versions_list: "Verzije", + empty: "Još nema verzija", + empty_named_only: "Nema imenovanih verzija", + current_version: "Trenutna verzija", + before_restore: "Prije vraćanja", + comparing_to: "Uspoređuje se s", + restored_from: (date: string) => `Vraćeno s ${date}`, + more_actions: "Više radnji", + version_name_input: "Naziv verzije", + name_version_menuitem: "Imenuj ovu verziju", + rename_menuitem: "Preimenuj", + compare_with_menuitem: "Usporedi s ovom verzijom", + restore_menuitem: "Vrati", + delete_menuitem: "Izbriši", + action_failed: "Nešto je pošlo po zlu. Pokušajte ponovno.", + history_load_failed: "Učitavanje povijesti verzija nije uspjelo", + }, exporter: { open_file: "Otvori datoteku", open_video_file: "Otvori videozapis", open_audio_file: "Otvori audiozapis", }, generic: { + loading: "Učitavanje...", + load_more: "Učitaj više", ctrl_shortcut: "Ctrl", form_submit: "U redu", }, diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index e7effe3827..55d3a860aa 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -423,17 +423,48 @@ export const is: Dictionary = { suggestion_changes: { formatting_change: "Sniðbreyting", deleted: "Eytt", + inserted: "Sett inn", inserted_by: (users: string) => `Sett inn af: ${users}`, deleted_by: (users: string) => `Eytt af: ${users}`, + changed: "Breytt", + changed_by: (users: string) => `Breytt af: ${users}`, formatting_change_by: (formats: string, users: string) => `Sniðbreyting (${formats}) af: ${users}`, }, + versioning: { + start_of_document: "Upphaf skjals", + compare_since_beginning_menuitem: "Bera saman frá upphafi", + title: "Ferill", + close: "Loka", + show_named_only: "Sýna aðeins nefndar útgáfur", + show_all: "Sýna allar útgáfur", + comparison_on: "Kveikja á samanburði", + comparison_off: "Slökkva á samanburði", + versions_list: "Útgáfur", + empty: "Engar útgáfur enn", + empty_named_only: "Engar nefndar útgáfur", + current_version: "Núverandi útgáfa", + before_restore: "Fyrir endurheimt", + comparing_to: "Borið saman við", + restored_from: (date: string) => `Endurheimt frá ${date}`, + more_actions: "Fleiri aðgerðir", + version_name_input: "Heiti útgáfu", + name_version_menuitem: "Nefna þessa útgáfu", + rename_menuitem: "Endurnefna", + compare_with_menuitem: "Bera saman við þessa útgáfu", + restore_menuitem: "Endurheimta", + delete_menuitem: "Eyða", + action_failed: "Eitthvað fór úrskeiðis. Reyndu aftur.", + history_load_failed: "Ekki tókst að hlaða útgáfusögu", + }, exporter: { open_file: "Opna skrá", open_video_file: "Opna myndband", open_audio_file: "Opna hljóð", }, generic: { + loading: "Hleður...", + load_more: "Hlaða meira", ctrl_shortcut: "Ctrl", form_submit: "Í lagi", }, diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 782a3c7fc4..005ceb6f7a 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -431,17 +431,48 @@ export const it: Dictionary = { suggestion_changes: { formatting_change: "Modifica formattazione", deleted: "Eliminato", + inserted: "Inserito", inserted_by: (users: string) => `Inserito da: ${users}`, deleted_by: (users: string) => `Eliminato da: ${users}`, + changed: "Modificato", + changed_by: (users: string) => `Modificato da: ${users}`, formatting_change_by: (formats: string, users: string) => `Modifica formattazione (${formats}) da: ${users}`, }, + versioning: { + start_of_document: "Inizio del documento", + compare_since_beginning_menuitem: "Confronta dall'inizio", + title: "Cronologia", + close: "Chiudi", + show_named_only: "Mostra solo le versioni con nome", + show_all: "Mostra tutte le versioni", + comparison_on: "Attiva il confronto", + comparison_off: "Disattiva il confronto", + versions_list: "Versioni", + empty: "Nessuna versione", + empty_named_only: "Nessuna versione con nome", + current_version: "Versione corrente", + before_restore: "Prima del ripristino", + comparing_to: "Confronto con", + restored_from: (date: string) => `Ripristinato dal ${date}`, + more_actions: "Altre azioni", + version_name_input: "Nome della versione", + name_version_menuitem: "Assegna un nome a questa versione", + rename_menuitem: "Rinomina", + compare_with_menuitem: "Confronta con questa versione", + restore_menuitem: "Ripristina", + delete_menuitem: "Elimina", + action_failed: "Qualcosa è andato storto. Riprova.", + history_load_failed: "Impossibile caricare la cronologia delle versioni", + }, exporter: { open_file: "Apri file", open_video_file: "Apri video", open_audio_file: "Apri audio", }, generic: { + loading: "Caricamento...", + load_more: "Carica altro", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 8bac14021d..93aa440e43 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -449,17 +449,48 @@ export const ja: Dictionary = { suggestion_changes: { formatting_change: "書式の変更", deleted: "削除済み", + inserted: "挿入済み", inserted_by: (users: string) => `挿入者: ${users}`, deleted_by: (users: string) => `削除者: ${users}`, + changed: "変更済み", + changed_by: (users: string) => `変更者: ${users}`, formatting_change_by: (formats: string, users: string) => `書式の変更 (${formats}) 変更者: ${users}`, }, + versioning: { + start_of_document: "ドキュメントの開始", + compare_since_beginning_menuitem: "最初から比較", + title: "履歴", + close: "閉じる", + show_named_only: "名前付きバージョンのみ表示", + show_all: "すべてのバージョンを表示", + comparison_on: "比較を有効にする", + comparison_off: "比較を無効にする", + versions_list: "バージョン", + empty: "バージョンはまだありません", + empty_named_only: "名前付きのバージョンはありません", + current_version: "現在のバージョン", + before_restore: "復元前", + comparing_to: "比較対象", + restored_from: (date: string) => `${date} から復元`, + more_actions: "その他の操作", + version_name_input: "バージョン名", + name_version_menuitem: "このバージョンに名前を付ける", + rename_menuitem: "名前を変更", + compare_with_menuitem: "このバージョンと比較", + restore_menuitem: "復元", + delete_menuitem: "削除", + action_failed: "問題が発生しました。もう一度お試しください。", + history_load_failed: "バージョン履歴を読み込めませんでした", + }, exporter: { open_file: "ファイルを開く", open_video_file: "動画を開く", open_audio_file: "音声を開く", }, generic: { + loading: "読み込み中...", + load_more: "さらに読み込む", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index de94329b19..85c4addb75 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -422,17 +422,48 @@ export const ko: Dictionary = { suggestion_changes: { formatting_change: "서식 변경", deleted: "삭제됨", + inserted: "삽입됨", inserted_by: (users: string) => `삽입한 사람: ${users}`, deleted_by: (users: string) => `삭제한 사람: ${users}`, + changed: "변경됨", + changed_by: (users: string) => `변경한 사람: ${users}`, formatting_change_by: (formats: string, users: string) => `서식 변경 (${formats}) 변경한 사람: ${users}`, }, + versioning: { + start_of_document: "문서 시작", + compare_since_beginning_menuitem: "처음부터 비교", + title: "기록", + close: "닫기", + show_named_only: "이름이 지정된 버전만 표시", + show_all: "모든 버전 표시", + comparison_on: "비교 켜기", + comparison_off: "비교 끄기", + versions_list: "버전", + empty: "아직 버전이 없습니다", + empty_named_only: "이름이 지정된 버전이 없습니다", + current_version: "현재 버전", + before_restore: "복원 전", + comparing_to: "비교 대상", + restored_from: (date: string) => `${date}에서 복원됨`, + more_actions: "추가 작업", + version_name_input: "버전 이름", + name_version_menuitem: "이 버전의 이름 지정", + rename_menuitem: "이름 바꾸기", + compare_with_menuitem: "이 버전과 비교", + restore_menuitem: "복원", + delete_menuitem: "삭제", + action_failed: "문제가 발생했습니다. 다시 시도해 주세요.", + history_load_failed: "버전 기록을 불러오지 못했습니다", + }, exporter: { open_file: "파일 열기", open_video_file: "동영상 열기", open_audio_file: "오디오 열기", }, generic: { + loading: "불러오는 중...", + load_more: "더 불러오기", ctrl_shortcut: "Ctrl", form_submit: "확인", }, diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index a90210b572..15a65c98b2 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -410,17 +410,48 @@ export const nl: Dictionary = { suggestion_changes: { formatting_change: "Opmaakwijziging", deleted: "Verwijderd", + inserted: "Ingevoegd", inserted_by: (users: string) => `Ingevoegd door: ${users}`, deleted_by: (users: string) => `Verwijderd door: ${users}`, + changed: "Gewijzigd", + changed_by: (users: string) => `Gewijzigd door: ${users}`, formatting_change_by: (formats: string, users: string) => `Opmaakwijziging (${formats}) door: ${users}`, }, + versioning: { + start_of_document: "Begin van document", + compare_since_beginning_menuitem: "Vergelijken vanaf het begin", + title: "Geschiedenis", + close: "Sluiten", + show_named_only: "Alleen benoemde versies tonen", + show_all: "Alle versies tonen", + comparison_on: "Vergelijking inschakelen", + comparison_off: "Vergelijking uitschakelen", + versions_list: "Versies", + empty: "Nog geen versies", + empty_named_only: "Geen benoemde versies", + current_version: "Huidige versie", + before_restore: "Voor herstel", + comparing_to: "Vergeleken met", + restored_from: (date: string) => `Hersteld vanaf ${date}`, + more_actions: "Meer acties", + version_name_input: "Versienaam", + name_version_menuitem: "Deze versie een naam geven", + rename_menuitem: "Naam wijzigen", + compare_with_menuitem: "Vergelijken met deze versie", + restore_menuitem: "Herstellen", + delete_menuitem: "Verwijderen", + action_failed: "Er is iets misgegaan. Probeer het opnieuw.", + history_load_failed: "Versiegeschiedenis kon niet worden geladen", + }, exporter: { open_file: "Bestand openen", open_video_file: "Video openen", open_audio_file: "Audio openen", }, generic: { + loading: "Laden...", + load_more: "Meer laden", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 9ed6388dc7..d67066c260 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -427,17 +427,48 @@ export const no: Dictionary = { suggestion_changes: { formatting_change: "Formateringsendring", deleted: "Slettet", + inserted: "Satt inn", inserted_by: (users: string) => `Satt inn av: ${users}`, deleted_by: (users: string) => `Slettet av: ${users}`, + changed: "Endret", + changed_by: (users: string) => `Endret av: ${users}`, formatting_change_by: (formats: string, users: string) => `Formateringsendring (${formats}) av: ${users}`, }, + versioning: { + start_of_document: "Starten av dokumentet", + compare_since_beginning_menuitem: "Sammenlign fra begynnelsen", + title: "Historikk", + close: "Lukk", + show_named_only: "Vis bare navngitte versjoner", + show_all: "Vis alle versjoner", + comparison_on: "Slå på sammenligning", + comparison_off: "Slå av sammenligning", + versions_list: "Versjoner", + empty: "Ingen versjoner ennå", + empty_named_only: "Ingen navngitte versjoner", + current_version: "Gjeldende versjon", + before_restore: "Før gjenoppretting", + comparing_to: "Sammenligner med", + restored_from: (date: string) => `Gjenopprettet fra ${date}`, + more_actions: "Flere handlinger", + version_name_input: "Versjonsnavn", + name_version_menuitem: "Gi denne versjonen et navn", + rename_menuitem: "Gi nytt navn", + compare_with_menuitem: "Sammenlign med denne versjonen", + restore_menuitem: "Gjenopprett", + delete_menuitem: "Slett", + action_failed: "Noe gikk galt. Prøv igjen.", + history_load_failed: "Kunne ikke laste versjonshistorikken", + }, exporter: { open_file: "Åpne fil", open_video_file: "Åpne video", open_audio_file: "Åpne lyd", }, generic: { + loading: "Laster...", + load_more: "Last inn mer", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index 95751640b9..f9da477084 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -400,17 +400,48 @@ export const pl: Dictionary = { suggestion_changes: { formatting_change: "Zmiana formatowania", deleted: "Usunięto", + inserted: "Wstawiono", inserted_by: (users: string) => `Wstawione przez: ${users}`, deleted_by: (users: string) => `Usunięte przez: ${users}`, + changed: "Zmieniono", + changed_by: (users: string) => `Zmienione przez: ${users}`, formatting_change_by: (formats: string, users: string) => `Zmiana formatowania (${formats}) przez: ${users}`, }, + versioning: { + start_of_document: "Początek dokumentu", + compare_since_beginning_menuitem: "Porównaj od początku", + title: "Historia", + close: "Zamknij", + show_named_only: "Pokaż tylko nazwane wersje", + show_all: "Pokaż wszystkie wersje", + comparison_on: "Włącz porównywanie", + comparison_off: "Wyłącz porównywanie", + versions_list: "Wersje", + empty: "Brak wersji", + empty_named_only: "Brak nazwanych wersji", + current_version: "Bieżąca wersja", + before_restore: "Przed przywróceniem", + comparing_to: "Porównanie z", + restored_from: (date: string) => `Przywrócono z ${date}`, + more_actions: "Więcej działań", + version_name_input: "Nazwa wersji", + name_version_menuitem: "Nazwij tę wersję", + rename_menuitem: "Zmień nazwę", + compare_with_menuitem: "Porównaj z tą wersją", + restore_menuitem: "Przywróć", + delete_menuitem: "Usuń", + action_failed: "Coś poszło nie tak. Spróbuj ponownie.", + history_load_failed: "Nie udało się załadować historii wersji", + }, exporter: { open_file: "Otwórz plik", open_video_file: "Otwórz wideo", open_audio_file: "Otwórz audio", }, generic: { + loading: "Ładowanie...", + load_more: "Wczytaj więcej", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index 6914de9d2c..cb5d10361e 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -402,17 +402,48 @@ export const pt: Dictionary = { suggestion_changes: { formatting_change: "Alteração de formatação", deleted: "Excluído", + inserted: "Inserido", inserted_by: (users: string) => `Inserido por: ${users}`, deleted_by: (users: string) => `Excluído por: ${users}`, + changed: "Alterado", + changed_by: (users: string) => `Alterado por: ${users}`, formatting_change_by: (formats: string, users: string) => `Alteração de formatação (${formats}) por: ${users}`, }, + versioning: { + start_of_document: "Início do documento", + compare_since_beginning_menuitem: "Comparar desde o início", + title: "Histórico", + close: "Fechar", + show_named_only: "Mostrar apenas versões nomeadas", + show_all: "Mostrar todas as versões", + comparison_on: "Ativar comparação", + comparison_off: "Desativar comparação", + versions_list: "Versões", + empty: "Ainda não há versões", + empty_named_only: "Não há versões nomeadas", + current_version: "Versão atual", + before_restore: "Antes da restauração", + comparing_to: "Comparando com", + restored_from: (date: string) => `Restaurado de ${date}`, + more_actions: "Mais ações", + version_name_input: "Nome da versão", + name_version_menuitem: "Nomear esta versão", + rename_menuitem: "Renomear", + compare_with_menuitem: "Comparar com esta versão", + restore_menuitem: "Restaurar", + delete_menuitem: "Excluir", + action_failed: "Algo deu errado. Tente novamente.", + history_load_failed: "Falha ao carregar o histórico de versões", + }, exporter: { open_file: "Abrir arquivo", open_video_file: "Abrir vídeo", open_audio_file: "Abrir áudio", }, generic: { + loading: "Carregando...", + load_more: "Carregar mais", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index db116a3c4c..1e942be3c8 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -453,17 +453,48 @@ export const ru: Dictionary = { suggestion_changes: { formatting_change: "Изменение форматирования", deleted: "Удалено", + inserted: "Вставлено", inserted_by: (users: string) => `Вставлено: ${users}`, deleted_by: (users: string) => `Удалено: ${users}`, + changed: "Изменено", + changed_by: (users: string) => `Изменено: ${users}`, formatting_change_by: (formats: string, users: string) => `Изменение форматирования (${formats}): ${users}`, }, + versioning: { + start_of_document: "Начало документа", + compare_since_beginning_menuitem: "Сравнить с начала", + title: "История", + close: "Закрыть", + show_named_only: "Показывать только именованные версии", + show_all: "Показывать все версии", + comparison_on: "Включить сравнение", + comparison_off: "Выключить сравнение", + versions_list: "Версии", + empty: "Пока нет версий", + empty_named_only: "Нет именованных версий", + current_version: "Текущая версия", + before_restore: "До восстановления", + comparing_to: "Сравнение с", + restored_from: (date: string) => `Восстановлено из ${date}`, + more_actions: "Другие действия", + version_name_input: "Название версии", + name_version_menuitem: "Назвать эту версию", + rename_menuitem: "Переименовать", + compare_with_menuitem: "Сравнить с этой версией", + restore_menuitem: "Восстановить", + delete_menuitem: "Удалить", + action_failed: "Что-то пошло не так. Попробуйте ещё раз.", + history_load_failed: "Не удалось загрузить историю версий", + }, exporter: { open_file: "Открыть файл", open_video_file: "Открыть видео", open_audio_file: "Открыть аудио", }, generic: { + loading: "Загрузка...", + load_more: "Загрузить ещё", ctrl_shortcut: "Ctrl", form_submit: "ОК", }, diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index f53c4c39d1..d3b3e3ce03 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -407,17 +407,48 @@ export const sk = { suggestion_changes: { formatting_change: "Zmena formátovania", deleted: "Odstránené", + inserted: "Vložené", inserted_by: (users: string) => `Vložil: ${users}`, deleted_by: (users: string) => `Odstránil: ${users}`, + changed: "Zmenené", + changed_by: (users: string) => `Zmenil: ${users}`, formatting_change_by: (formats: string, users: string) => `Zmena formátovania (${formats}) od: ${users}`, }, + versioning: { + start_of_document: "Začiatok dokumentu", + compare_since_beginning_menuitem: "Porovnať od začiatku", + title: "História", + close: "Zavrieť", + show_named_only: "Zobraziť iba pomenované verzie", + show_all: "Zobraziť všetky verzie", + comparison_on: "Zapnúť porovnávanie", + comparison_off: "Vypnúť porovnávanie", + versions_list: "Verzie", + empty: "Zatiaľ žiadne verzie", + empty_named_only: "Žiadne pomenované verzie", + current_version: "Aktuálna verzia", + before_restore: "Pred obnovením", + comparing_to: "Porovnáva sa s", + restored_from: (date: string) => `Obnovené z ${date}`, + more_actions: "Ďalšie akcie", + version_name_input: "Názov verzie", + name_version_menuitem: "Pomenovať túto verziu", + rename_menuitem: "Premenovať", + compare_with_menuitem: "Porovnať s touto verziou", + restore_menuitem: "Obnoviť", + delete_menuitem: "Odstrániť", + action_failed: "Niečo sa pokazilo. Skúste to znova.", + history_load_failed: "Nepodarilo sa načítať históriu verzií", + }, exporter: { open_file: "Otvoriť súbor", open_video_file: "Otvoriť video", open_audio_file: "Otvoriť zvuk", }, generic: { + loading: "Načítava sa...", + load_more: "Načítať viac", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index e6101c8f69..b71784b1fd 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -433,17 +433,48 @@ export const uk: Dictionary = { suggestion_changes: { formatting_change: "Зміна форматування", deleted: "Видалено", + inserted: "Вставлено", inserted_by: (users: string) => `Вставлено користувачем: ${users}`, deleted_by: (users: string) => `Видалено користувачем: ${users}`, + changed: "Змінено", + changed_by: (users: string) => `Змінено користувачем: ${users}`, formatting_change_by: (formats: string, users: string) => `Зміна форматування (${formats}) користувачем: ${users}`, }, + versioning: { + start_of_document: "Початок документа", + compare_since_beginning_menuitem: "Порівняти від початку", + title: "Історія", + close: "Закрити", + show_named_only: "Показувати лише названі версії", + show_all: "Показувати всі версії", + comparison_on: "Увімкнути порівняння", + comparison_off: "Вимкнути порівняння", + versions_list: "Версії", + empty: "Версій ще немає", + empty_named_only: "Немає іменованих версій", + current_version: "Поточна версія", + before_restore: "До відновлення", + comparing_to: "Порівняння з", + restored_from: (date: string) => `Відновлено з ${date}`, + more_actions: "Інші дії", + version_name_input: "Назва версії", + name_version_menuitem: "Назвати цю версію", + rename_menuitem: "Перейменувати", + compare_with_menuitem: "Порівняти з цією версією", + restore_menuitem: "Відновити", + delete_menuitem: "Видалити", + action_failed: "Щось пішло не так. Спробуйте ще раз.", + history_load_failed: "Не вдалося завантажити історію версій", + }, exporter: { open_file: "Відкрити файл", open_video_file: "Відкрити відео", open_audio_file: "Відкрити аудіо", }, generic: { + loading: "Завантаження...", + load_more: "Завантажити ще", ctrl_shortcut: "Ctrl", form_submit: "ОК", }, diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 23b0f4f1a7..f057f88ca9 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -443,17 +443,48 @@ export const uz: Dictionary = { suggestion_changes: { formatting_change: "Formatlash o'zgarishi", deleted: "O'chirildi", + inserted: "Qo'shildi", inserted_by: (users: string) => `Qo'shgan: ${users}`, deleted_by: (users: string) => `O'chirgan: ${users}`, + changed: "O'zgartirildi", + changed_by: (users: string) => `O'zgartirgan: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`, }, + versioning: { + start_of_document: "Hujjat boshi", + compare_since_beginning_menuitem: "Boshidan taqqoslash", + title: "Tarix", + close: "Yopish", + show_named_only: "Faqat nomlangan versiyalarni ko'rsatish", + show_all: "Barcha versiyalarni ko'rsatish", + comparison_on: "Taqqoslashni yoqish", + comparison_off: "Taqqoslashni o'chirish", + versions_list: "Versiyalar", + empty: "Hozircha versiyalar yo'q", + empty_named_only: "Nomlangan versiyalar yo'q", + current_version: "Joriy versiya", + before_restore: "Tiklashdan oldin", + comparing_to: "Taqqoslanmoqda", + restored_from: (date: string) => `${date} dan tiklangan`, + more_actions: "Boshqa amallar", + version_name_input: "Versiya nomi", + name_version_menuitem: "Bu versiyaga nom berish", + rename_menuitem: "Nomini o'zgartirish", + compare_with_menuitem: "Shu versiya bilan taqqoslash", + restore_menuitem: "Tiklash", + delete_menuitem: "O'chirish", + action_failed: "Xatolik yuz berdi. Qayta urinib ko‘ring.", + history_load_failed: "Versiyalar tarixini yuklab bo‘lmadi", + }, exporter: { open_file: "Faylni ochish", open_video_file: "Videoni ochish", open_audio_file: "Audioni ochish", }, generic: { + loading: "Yuklanmoqda...", + load_more: "Ko‘proq yuklash", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index d52db4d48d..801ce1bdf6 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -408,17 +408,48 @@ export const vi: Dictionary = { suggestion_changes: { formatting_change: "Thay đổi định dạng", deleted: "Đã xóa", + inserted: "Đã chèn", inserted_by: (users: string) => `Được chèn bởi: ${users}`, deleted_by: (users: string) => `Được xóa bởi: ${users}`, + changed: "Đã thay đổi", + changed_by: (users: string) => `Được thay đổi bởi: ${users}`, formatting_change_by: (formats: string, users: string) => `Thay đổi định dạng (${formats}) bởi: ${users}`, }, + versioning: { + start_of_document: "Bắt đầu tài liệu", + compare_since_beginning_menuitem: "So sánh từ đầu", + title: "Lịch sử", + close: "Đóng", + show_named_only: "Chỉ hiển thị các phiên bản đã đặt tên", + show_all: "Hiển thị tất cả phiên bản", + comparison_on: "Bật so sánh", + comparison_off: "Tắt so sánh", + versions_list: "Phiên bản", + empty: "Chưa có phiên bản nào", + empty_named_only: "Không có phiên bản nào được đặt tên", + current_version: "Phiên bản hiện tại", + before_restore: "Trước khi khôi phục", + comparing_to: "Đang so sánh với", + restored_from: (date: string) => `Khôi phục từ ${date}`, + more_actions: "Thao tác khác", + version_name_input: "Tên phiên bản", + name_version_menuitem: "Đặt tên cho phiên bản này", + rename_menuitem: "Đổi tên", + compare_with_menuitem: "So sánh với phiên bản này", + restore_menuitem: "Khôi phục", + delete_menuitem: "Xóa", + action_failed: "Đã xảy ra lỗi. Vui lòng thử lại.", + history_load_failed: "Không thể tải lịch sử phiên bản", + }, exporter: { open_file: "Mở tệp", open_video_file: "Mở video", open_audio_file: "Mở âm thanh", }, generic: { + loading: "Đang tải...", + load_more: "Tải thêm", ctrl_shortcut: "Ctrl", form_submit: "OK", }, diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 0aba71ead4..7f3626c55c 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -450,17 +450,48 @@ export const zhTW: Dictionary = { suggestion_changes: { formatting_change: "格式變更", deleted: "已刪除", + inserted: "已插入", inserted_by: (users: string) => `插入者:${users}`, deleted_by: (users: string) => `刪除者:${users}`, + changed: "已變更", + changed_by: (users: string) => `變更者:${users}`, formatting_change_by: (formats: string, users: string) => `格式變更(${formats}),變更者:${users}`, }, + versioning: { + start_of_document: "文件開始", + compare_since_beginning_menuitem: "從頭開始比較", + title: "版本紀錄", + close: "關閉", + show_named_only: "僅顯示已命名的版本", + show_all: "顯示所有版本", + comparison_on: "開啟比較", + comparison_off: "關閉比較", + versions_list: "版本", + empty: "尚無版本", + empty_named_only: "尚無命名版本", + current_version: "目前版本", + before_restore: "還原前", + comparing_to: "比較對象", + restored_from: (date: string) => `已從 ${date} 還原`, + more_actions: "更多操作", + version_name_input: "版本名稱", + name_version_menuitem: "為此版本命名", + rename_menuitem: "重新命名", + compare_with_menuitem: "與此版本比較", + restore_menuitem: "還原", + delete_menuitem: "刪除", + action_failed: "發生錯誤,請再試一次。", + history_load_failed: "無法載入版本歷史記錄", + }, exporter: { open_file: "開啟檔案", open_video_file: "開啟影片", open_audio_file: "開啟音訊", }, generic: { + loading: "載入中...", + load_more: "載入更多", ctrl_shortcut: "Ctrl", form_submit: "確定", }, diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 0017c86672..e485eb943b 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -450,17 +450,48 @@ export const zh: Dictionary = { suggestion_changes: { formatting_change: "格式更改", deleted: "已删除", + inserted: "已插入", inserted_by: (users: string) => `插入者:${users}`, deleted_by: (users: string) => `删除者:${users}`, + changed: "已更改", + changed_by: (users: string) => `更改者:${users}`, formatting_change_by: (formats: string, users: string) => `格式更改(${formats}),更改者:${users}`, }, + versioning: { + start_of_document: "文档开始", + compare_since_beginning_menuitem: "从头开始比较", + title: "历史记录", + close: "关闭", + show_named_only: "仅显示已命名的版本", + show_all: "显示所有版本", + comparison_on: "开启对比", + comparison_off: "关闭对比", + versions_list: "版本", + empty: "暂无版本", + empty_named_only: "暂无命名版本", + current_version: "当前版本", + before_restore: "恢复前", + comparing_to: "对比对象", + restored_from: (date: string) => `恢复自 ${date}`, + more_actions: "更多操作", + version_name_input: "版本名称", + name_version_menuitem: "命名此版本", + rename_menuitem: "重命名", + compare_with_menuitem: "与此版本对比", + restore_menuitem: "恢复", + delete_menuitem: "删除", + action_failed: "出错了,请重试。", + history_load_failed: "无法加载版本历史记录", + }, exporter: { open_file: "打开文件", open_video_file: "打开视频", open_audio_file: "打开音频", }, generic: { + loading: "加载中...", + load_more: "加载更多", ctrl_shortcut: "Ctrl", form_submit: "确定", }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de5faa2fc7..832edc5942 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,8 +16,13 @@ export * from "./editor/BlockNoteExtension.js"; export * from "./editor/defaultColors.js"; export * from "./editor/selectionTypes.js"; export * from "./exporter/index.js"; -export * from "./extensions/index.js"; export * from "./extensions-shared/UiElementPosition.js"; +export { getDefaultEmojiPickerItems } from "./extensions/SuggestionMenu/getDefaultEmojiPickerItems.js"; +export { + filterSuggestionItems, + getDefaultSlashMenuItems, + insertOrUpdateBlockForSlashMenu, +} from "./extensions/SuggestionMenu/getDefaultSlashMenuItems.js"; export * from "./i18n/dictionary.js"; export * from "./schema/index.js"; export * from "./user/index.js"; @@ -44,7 +49,6 @@ export { selectedFragmentToHTML } from "./api/clipboard/toClipboard/copyExtensio export * from "./api/nodeConversions/blockToNode.js"; export * from "./api/nodeConversions/fragmentToBlocks.js"; export * from "./api/nodeConversions/nodeToBlock.js"; -export * from "./extensions/tiptap-extensions/UniqueID/UniqueID.js"; // for server-util (TODO: maybe move): export * from "./api/exporters/markdown/markdownExporter.js"; diff --git a/packages/core/src/user/userColors.test.ts b/packages/core/src/user/userColors.test.ts new file mode 100644 index 0000000000..2d130d1257 --- /dev/null +++ b/packages/core/src/user/userColors.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { User } from "./UserStore.js"; +import { createUserStore } from "./UserStore.js"; +import { + colorsForUserIds, + fallbackColorForUserId, + userColorPalette, + userMarkColors, +} from "./userColors.js"; + +describe("userColorPalette", () => { + it("contains no red", () => { + // The palette tints insertions as well as deletions, so a red entry would + // make one author's additions read as errors. Guard the property rather + // than the exact hexes: red is any entry whose hue is near 0°/360°. + for (const { light, dark } of userColorPalette) { + for (const color of [light, dark]) { + const [r, g, b] = [1, 3, 5].map((offset) => + parseInt(color.slice(offset, offset + 2), 16), + ); + const isRed = r > g + 40 && r > b + 40; + expect(isRed, `${color} reads as red`).toBe(false); + } + } + }); + + it("assigns a stable entry per user id", () => { + expect(fallbackColorForUserId("alice")).toEqual( + fallbackColorForUserId("alice"), + ); + expect(userColorPalette).toContainEqual(fallbackColorForUserId("alice")); + }); +}); + +describe("userMarkColors", () => { + it("is undefined for a user with no color", () => { + expect(userMarkColors(undefined)).toBeUndefined(); + expect(userMarkColors({})).toBeUndefined(); + }); + + it("uses both colors when the app supplies both", () => { + expect(userMarkColors({ color: "#123456", colorLight: "#abcdef" })).toEqual( + { + light: "#abcdef", + dark: "#123456", + }, + ); + }); + + it("derives the light tint when only `color` is set", () => { + expect(userMarkColors({ color: "#123456" })).toEqual({ + light: "color-mix(in srgb, #123456 30%, white)", + dark: "#123456", + }); + }); +}); + +describe("colorsForUserIds", () => { + it("falls back to the first palette entry with no ids", () => { + const store = createUserStore(async () => []); + expect(colorsForUserIds(store, undefined)).toEqual(userColorPalette[0]); + expect(colorsForUserIds(store, [])).toEqual(userColorPalette[0]); + }); + + it("falls back to the id's palette entry for an unresolved user", () => { + const store = createUserStore(async () => []); + expect(colorsForUserIds(store, ["alice"])).toEqual( + fallbackColorForUserId("alice"), + ); + }); + + it("uses the resolved user's own colors, deriving the tint when needed", async () => { + const store = createUserStore(async (ids: string[]) => + ids.map((id) => ({ + id, + username: id, + avatarUrl: "", + color: id === "both" ? "#123456" : "#654321", + ...(id === "both" ? { colorLight: "#abcdef" } : {}), + })), + ); + await store.loadUsers(["both", "dark-only"]); + + expect(colorsForUserIds(store, ["both"])).toEqual({ + light: "#abcdef", + dark: "#123456", + }); + expect(colorsForUserIds(store, ["dark-only"])).toEqual({ + light: "color-mix(in srgb, #654321 30%, white)", + dark: "#654321", + }); + }); +}); diff --git a/packages/core/src/user/userColors.ts b/packages/core/src/user/userColors.ts index b63e909238..fdad687cc9 100644 --- a/packages/core/src/user/userColors.ts +++ b/packages/core/src/user/userColors.ts @@ -1,5 +1,5 @@ import { digestString } from "lib0/hash/fnv1a"; -import type { UserStore } from "./UserStore.js"; +import type { User, UserStore } from "./UserStore.js"; /** * Deterministic hash of a string to an unsigned 32-bit integer. @@ -12,13 +12,21 @@ const hashStr = (s: string): number => { return Math.abs(hash); }; -/** Fallback palette used when a user has no resolved color of their own. */ +/** + * Fallback palette used when a user has no resolved color of their own. + * + * Deliberately red-free: these colors tint *insertions* as well as deletions, + * and a red insertion reads as an error rather than as one author's + * contribution. The hues are spread far enough apart to stay distinguishable + * for the most common forms of colour-vision deficiency. + */ export const userColorPalette: Array<{ light: string; dark: string }> = [ - { light: "#fff0c2", dark: "#8a6d1a" }, - { light: "#fcc9c3", dark: "#8a2e24" }, - { light: "#d4e8eb", dark: "#4a7178" }, - { light: "#c2eeff", dark: "#1a6e8a" }, - { light: "#bef3ff", dark: "#0a7a8a" }, + { light: "#fff0c2", dark: "#8a6d1a" }, // amber + { light: "#dcdefc", dark: "#3b3f9c" }, // indigo + { light: "#c9efe9", dark: "#0f6e62" }, // teal + { light: "#c9dcff", dark: "#1e4fb0" }, // blue + { light: "#eadcfb", dark: "#6b2fa3" }, // violet + { light: "#dfe4ea", dark: "#46525f" }, // slate ]; /** The deterministic {@link userColorPalette} entry for a single user id. */ @@ -28,7 +36,28 @@ export const fallbackColorForUserId = ( userColorPalette[hashStr(id) % userColorPalette.length]; /** - * The (first) user's resolved color from the {@link UserStore}, or their + * A user's own mark colors, or `undefined` when they have none. + * + * `color` is the saturated color the app already uses for that user (cursors, + * avatars); `colorLight` is the pale background a mark is highlighted with. Most + * applications only set the former, so derive the latter rather than fall back + * to a palette entry that has nothing to do with the user's actual color — a + * user whose cursor is green shouldn't have amber marks. + */ +export const userMarkColors = ( + user: Pick | undefined, +): { light: string; dark: string } | undefined => { + if (!user?.color) { + return undefined; + } + return { + light: user.colorLight ?? `color-mix(in srgb, ${user.color} 30%, white)`, + dark: user.color, + }; +}; + +/** + * The (first) user's {@link userMarkColors}, or their * {@link fallbackColorForUserId} palette entry. Used where a concrete color * string is needed (the portaled hover tooltip); marks themselves use the * cascaded {@link userColorVarNames} properties instead. @@ -41,11 +70,10 @@ export const colorsForUserIds = ( return userColorPalette[0]; } const firstId = userIds[0]; - const user = userStore.getUser(firstId); - if (user?.color && user.colorLight) { - return { light: user.colorLight, dark: user.color }; - } - return fallbackColorForUserId(firstId); + return ( + userMarkColors(userStore.getUser(firstId)) ?? + fallbackColorForUserId(firstId) + ); }; /** diff --git a/packages/core/src/y/comments/RESTYjsThreadStore.ts b/packages/core/src/y/comments/RESTYjsThreadStore.ts index 7841f453f4..d14d69d13a 100644 --- a/packages/core/src/y/comments/RESTYjsThreadStore.ts +++ b/packages/core/src/y/comments/RESTYjsThreadStore.ts @@ -21,7 +21,7 @@ export class RESTYjsThreadStore extends YjsThreadStoreBase { constructor( private readonly BASE_URL: string, private readonly headers: Record, - threadsYType: Y.Type, + threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(threadsYType, auth); diff --git a/packages/core/src/y/comments/YjsThreadStore.test.ts b/packages/core/src/y/comments/YjsThreadStore.test.ts index 84ce8c47f4..9683393f55 100644 --- a/packages/core/src/y/comments/YjsThreadStore.test.ts +++ b/packages/core/src/y/comments/YjsThreadStore.test.ts @@ -14,7 +14,7 @@ vi.mock("lib0/random", async (importOriginal) => ({ describe("YjsThreadStore (@y/y v14)", () => { let store: YjsThreadStore; let doc: Y.Doc; - let threadsYType: Y.Type; + let threadsYType: Y.Node; beforeEach(() => { // Reset mocks and create fresh instances diff --git a/packages/core/src/y/comments/YjsThreadStore.ts b/packages/core/src/y/comments/YjsThreadStore.ts index 0a9b09a676..82ab3433a7 100644 --- a/packages/core/src/y/comments/YjsThreadStore.ts +++ b/packages/core/src/y/comments/YjsThreadStore.ts @@ -29,7 +29,7 @@ import { export class YjsThreadStore extends YjsThreadStoreBase { constructor( private readonly userId: string, - threadsYType: Y.Type, + threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(threadsYType, auth); @@ -98,7 +98,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { threadId: string; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -121,7 +121,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { body: options.comment.body, }; - (yThread.getAttr("comments") as Y.Type).push([commentToYType(comment)]); + (yThread.getAttr("comments") as Y.Node).push([commentToYType(comment)]); yThread.setAttr("updatedAt", new Date().getTime()); return comment; @@ -138,23 +138,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { commentId: string; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canUpdateComment(yTypeToComment(yComment))) { throw new Error("Not authorized"); @@ -173,23 +173,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { softDelete?: boolean; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canDeleteComment(yTypeToComment(yComment))) { throw new Error("Not authorized"); @@ -209,7 +209,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { if ( commentsType .toArray() - .every((comment) => (comment as Y.Type).getAttr("deletedAt")) + .every((comment) => (comment as Y.Node).getAttr("deletedAt")) ) { // all comments deleted if (options.softDelete) { @@ -226,7 +226,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public deleteThread = this.transact((options: { threadId: string }) => { if ( !this.auth.canDeleteThread( - yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Type), + yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Node), ) ) { throw new Error("Not authorized"); @@ -237,7 +237,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public resolveThread = this.transact((options: { threadId: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -254,7 +254,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public unresolveThread = this.transact((options: { threadId: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -271,23 +271,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { public addReaction = this.transact( (options: { threadId: string; commentId: string; emoji: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canAddReaction(yTypeToComment(yComment), options.emoji)) { throw new Error("Not authorized"); @@ -297,13 +297,13 @@ export class YjsThreadStore extends YjsThreadStoreBase { const key = `${this.userId}-${options.emoji}`; - const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node; if (reactionsByUser.hasAttr(key)) { // already exists return; } else { - const reaction = new Y.Type(); + const reaction = new Y.Node(); reaction.setAttr("emoji", options.emoji); reaction.setAttr("createdAt", date.getTime()); reaction.setAttr("userId", this.userId); @@ -315,23 +315,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { public deleteReaction = this.transact( (options: { threadId: string; commentId: string; emoji: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if ( !this.auth.canDeleteReaction(yTypeToComment(yComment), options.emoji) @@ -341,14 +341,14 @@ export class YjsThreadStore extends YjsThreadStoreBase { const key = `${this.userId}-${options.emoji}`; - const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node; reactionsByUser.deleteAttr(key); }, ); } -function yTypeFindIndex(yType: Y.Type, predicate: (item: any) => boolean) { +function yTypeFindIndex(yType: Y.Node, predicate: (item: any) => boolean) { for (let i = 0; i < yType.length; i++) { if (predicate(yType.get(i))) { return i; diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts index b62c2e1811..c76c890f18 100644 --- a/packages/core/src/y/comments/YjsThreadStoreBase.ts +++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts @@ -10,7 +10,7 @@ import { yTypeToThread } from "./yjsHelpers.js"; */ export abstract class YjsThreadStoreBase extends ThreadStore { constructor( - protected readonly threadsYType: Y.Type, + protected readonly threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(auth); @@ -29,7 +29,7 @@ export abstract class YjsThreadStoreBase extends ThreadStore { public getThreads(): Map { const threadMap = new Map(); this.threadsYType.forEachAttr((yThread: any, id: string | number) => { - if (yThread instanceof Y.Type) { + if (yThread instanceof Y.Node) { threadMap.set(String(id), yTypeToThread(yThread)); } }); diff --git a/packages/core/src/y/comments/yjsHelpers.ts b/packages/core/src/y/comments/yjsHelpers.ts index 1ed4ff492f..c485143e8e 100644 --- a/packages/core/src/y/comments/yjsHelpers.ts +++ b/packages/core/src/y/comments/yjsHelpers.ts @@ -6,7 +6,7 @@ import type { } from "../../comments/types.js"; export function commentToYType(comment: CommentData) { - const yType = new Y.Type(); + const yType = new Y.Node(); yType.setAttr("id", comment.id); yType.setAttr("userId", comment.userId); yType.setAttr("createdAt", comment.createdAt.getTime()); @@ -26,18 +26,18 @@ export function commentToYType(comment: CommentData) { * this makes it easy to add / remove reactions and in a way that works local-first. * The cost is that "reading" the reactions is a bit more complex (see yTypeToReactions). */ - yType.setAttr("reactionsByUser", new Y.Type()); + yType.setAttr("reactionsByUser", new Y.Node()); yType.setAttr("metadata", comment.metadata); return yType; } export function threadToYType(thread: ThreadData) { - const yType = new Y.Type(); + const yType = new Y.Node(); yType.setAttr("id", thread.id); yType.setAttr("createdAt", thread.createdAt.getTime()); yType.setAttr("updatedAt", thread.updatedAt.getTime()); - const commentsType = new Y.Type(); + const commentsType = new Y.Node(); commentsType.push(thread.comments.map((comment) => commentToYType(comment))); @@ -55,7 +55,7 @@ type SingleUserCommentReactionData = { userId: string; }; -export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData { +export function yTypeToReaction(yType: Y.Node): SingleUserCommentReactionData { return { emoji: yType.getAttr("emoji"), createdAt: new Date(yType.getAttr("createdAt")), @@ -63,8 +63,8 @@ export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData { }; } -function yTypeToReactions(yType: Y.Type): CommentReactionData[] { - const flatReactions = [...yType.attrValues()].map((reaction: Y.Type) => +function yTypeToReactions(yType: Y.Node): CommentReactionData[] { + const flatReactions = [...yType.attrValues()].map((reaction: Y.Node) => yTypeToReaction(reaction), ); // combine reactions by the same emoji @@ -92,7 +92,7 @@ function yTypeToReactions(yType: Y.Type): CommentReactionData[] { ); } -export function yTypeToComment(yType: Y.Type): CommentData { +export function yTypeToComment(yType: Y.Node): CommentData { return { type: "comment", id: yType.getAttr("id"), @@ -108,14 +108,14 @@ export function yTypeToComment(yType: Y.Type): CommentData { }; } -export function yTypeToThread(yType: Y.Type): ThreadData { +export function yTypeToThread(yType: Y.Node): ThreadData { return { type: "thread", id: yType.getAttr("id"), createdAt: new Date(yType.getAttr("createdAt")), updatedAt: new Date(yType.getAttr("updatedAt")), - comments: ((yType.getAttr("comments") as Y.Type)?.toArray() || []).map( - (comment) => yTypeToComment(comment as Y.Type), + comments: ((yType.getAttr("comments") as Y.Node)?.toArray() || []).map( + (comment) => yTypeToComment(comment as Y.Node), ), resolved: yType.getAttr("resolved"), resolvedUpdatedAt: new Date(yType.getAttr("resolvedUpdatedAt")), diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index f752b48182..7d4d0456eb 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { User } from "../../user/index.js"; +import { cssVarUserId } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; // Editors created during a test, destroyed in afterEach: an undestroyed @@ -12,11 +13,12 @@ import { AttributionExtension } from "./AttributionExtension.js"; // the jsdom environment is torn down ("document is not defined" as an // unhandled error - flaky, timing-dependent, mostly on slow CI). const editors: BlockNoteEditor[] = []; +const mounts: HTMLElement[] = []; // A `resolveUsers` spy plus an editor with the AttributionExtension registered. // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. -function createEditor() { +function createEditor(user?: Partial) { const resolveUsers = vi.fn(async (ids: string[]): Promise => ids.map((id) => ({ id, @@ -24,18 +26,32 @@ function createEditor() { avatarUrl: "", color: "#123456", colorLight: "#abcdef", + ...user, })), ); const editor = BlockNoteEditor.create({ extensions: [AttributionExtension({ resolveUsers })], }); - editor.mount(document.createElement("div")); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mounts.push(mount); + editor.mount(mount); editors.push(editor); return { editor, resolveUsers }; } +/** The `--user-color--{light,dark}` values on the editor root. */ +function rootColorVars(editor: BlockNoteEditor, userId: string) { + const root = editor.prosemirrorView!.dom as HTMLElement; + const key = cssVarUserId(userId); + return { + light: root.style.getPropertyValue(`--user-color-${key}-light`), + dark: root.style.getPropertyValue(`--user-color-${key}-dark`), + }; +} + // Add a `y-attributed-insert` mark carrying `userIds` over the first block's // text, mirroring how the sync reconcile applies attribution marks. function addInsertMark(editor: BlockNoteEditor, userIds: string[]) { @@ -56,6 +72,9 @@ describe("AttributionExtension user loading", () => { for (const editor of editors.splice(0)) { editor._tiptapEditor.destroy(); } + for (const mount of mounts.splice(0)) { + mount.remove(); + } vi.restoreAllMocks(); }); @@ -71,6 +90,51 @@ describe("AttributionExtension user loading", () => { expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything()); }); + it("replaces attribution authors while allowing different attribution kinds to coexist", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + const names = [ + "y-attributed-insert", + "y-attributed-delete", + "y-attributed-format", + ]; + for (const author of ["alice", "bob"]) { + editor.transact((tr) => { + tr.doc.descendants((node, pos) => { + if (node.isText) { + for (const name of names) { + tr.addMark( + pos, + pos + node.nodeSize, + editor.pmSchema.marks[name].create({ + userIds: [author], + ...(name === "y-attributed-format" + ? { format: { bold: [author] } } + : {}), + }), + ); + } + } + }); + }); + } + editor.prosemirrorState.doc.descendants((node) => { + if (node.isText) { + expect(node.marks).toHaveLength(3); + expect(node.marks.map((mark) => mark.type.name).sort()).toEqual( + [...names].sort(), + ); + for (const mark of node.marks) { + expect(mark.attrs.userIds).toEqual(["bob"]); + } + expect( + node.marks.find((mark) => mark.type.name === "y-attributed-format")! + .attrs.format, + ).toEqual({ bold: ["bob"] }); + } + }); + }); + it("does not load users for changes without attribution marks", () => { const { editor, resolveUsers } = createEditor(); editor.replaceBlocks(editor.document, [{ content: "hello" }]); @@ -92,4 +156,209 @@ describe("AttributionExtension user loading", () => { // The user store dedupes already-cached ids, so `alice` is fetched once. expect(resolveUsers).toHaveBeenCalledTimes(1); }); + + it("writes both of a resolved author's colors to the editor root", async () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + + addInsertMark(editor, ["alice"]); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).not.toBe(""), + ); + + expect(rootColorVars(editor, "alice")).toEqual({ + light: "#abcdef", + dark: "#123456", + }); + }); + + it("derives the light tint for an author that only has a `color`", async () => { + const { editor } = createEditor({ colorLight: undefined }); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + + addInsertMark(editor, ["alice"]); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).not.toBe(""), + ); + + expect(rootColorVars(editor, "alice")).toEqual({ + light: "color-mix(in srgb, #123456 30%, white)", + dark: "#123456", + }); + }); + + it("loads property authors, replaces stale attribution, and shows the changed keys", async () => { + const { editor, resolveUsers } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const originalBlocks = editor.document; + const markType = editor.pmSchema.marks["y-attributed-attrs"]; + function setChanges( + changes: Record, + ) { + editor.transact((tr) => tr.addNodeMark(2, markType.create({ changes }))); + } + setChanges({ textAlignment: { userIds: ["alice"], timestamp: null } }); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).toBe("#123456"), + ); + expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything()); + setChanges({ backgroundColor: { userIds: ["bob"], timestamp: null } }); + await vi.waitFor(() => + expect(rootColorVars(editor, "bob").dark).toBe("#123456"), + ); + expect(editor.prosemirrorState.doc.nodeAt(2)!.marks).toHaveLength(1); + expect(editor.document).toEqual(originalBlocks); + const wrapper = + editor.prosemirrorView.dom.querySelector( + "[data-attributes]", + )!; + wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "attrs", + attributes: ["backgroundColor"], + users: ["name-bob"], + contentType: "block", + }); + }); + + it("keeps deletion styling directly on the node when attributes are also attributed", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + editor.transact((tr) => { + tr.addNodeMark( + 2, + editor.pmSchema.marks["y-attributed-delete"].create({ + userIds: ["alice"], + }), + ); + tr.addNodeMark( + 2, + editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: ["alice"], timestamp: null } }, + }), + ); + }); + expect( + editor.prosemirrorView.dom.querySelector( + "[data-attributes] > span > del > .bn-suggestion-node--delete > .bn-block-content", + ), + ).not.toBeNull(); + }); + + it("only opens a tooltip in the editor containing the hovered mark", () => { + const { editor } = createEditor(); + const { editor: otherEditor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + const mark = editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: [], timestamp: null } }, + }); + editor.transact((tr) => tr.addNodeMark(2, mark)); + editor.prosemirrorView.dom + .querySelector("[data-attributes]")! + .dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toBeDefined(); + expect( + otherEditor.getExtension(AttributionExtension)!.store.state, + ).toBeUndefined(); + otherEditor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toBeUndefined(); + }); + + it("shows changed properties even when a version diff has no author", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const mark = editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: [], timestamp: null } }, + }); + editor.transact((tr) => tr.addNodeMark(2, mark)); + const wrapper = + editor.prosemirrorView.dom.querySelector( + "[data-attributes]", + )!; + wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "attrs", + attributes: ["textAlignment"], + users: [], + }); + }); + + it("does not show a change to a block's id", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const markType = editor.pmSchema.marks["y-attributed-attrs"]; + const id = { userIds: ["alice"], timestamp: null }; + editor.transact((tr) => + tr.addNodeMark(2, markType.create({ changes: { id } })), + ); + const block = + editor.prosemirrorView.dom.querySelector(".bn-block-content")!; + block.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.prosemirrorView.dom.querySelector("[data-user-ids]"), + ).toBeNull(); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toBeUndefined(); + + editor.transact((tr) => + tr.addNodeMark( + 2, + markType.create({ changes: { id, textAlignment: id } }), + ), + ); + editor.prosemirrorView.dom + .querySelector("[data-attributes]")! + .dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "attrs", + attributes: ["textAlignment"], + }); + }); + + it("names only the authors of the attribute changes it shows", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const markType = editor.pmSchema.marks["y-attributed-attrs"]; + editor.transact((tr) => + tr.addNodeMark( + 2, + markType.create({ + changes: { + id: { userIds: ["bob"], timestamp: null }, + textAlignment: { userIds: ["alice"], timestamp: null }, + }, + }), + ), + ); + const mark = + editor.prosemirrorView.dom.querySelector( + "[data-attributes]", + )!; + expect(JSON.parse(mark.dataset["userIds"]!)).toEqual(["alice"]); + mark.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ attributes: ["textAlignment"], users: ["alice"] }); + }); }); diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 10e888830e..4f47e96a8b 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -1,3 +1,4 @@ +import { AddNodeMarkStep } from "prosemirror-transform"; import { getChangedRanges } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; import { @@ -8,12 +9,15 @@ import { import { colorsForUserIds, userColorVarNames, + userMarkColors, normalizeToUserStore, type UserStoreOrResolver, } from "../../user/index.js"; import { resolveAttributionMarkClassName, + getAttributionUserIds, YAttributionMarksExtension, + type AttributionMarkStyleInfo, type GetAttributionMarkClassName, } from "./YAttributionMarks.js"; @@ -22,6 +26,7 @@ const ATTRIBUTION_MARK_TYPES = { "y-attributed-insert": "insert", "y-attributed-delete": "delete", "y-attributed-format": "format", + "y-attributed-attrs": "attrs", } as const; const ATTRIBUTION_LOAD_PLUGIN_KEY = new PluginKey("attributionLoadUsers"); @@ -66,58 +71,36 @@ const parseFormatKeys = (formatJSON: string | undefined): string[] => { return Object.keys(format); }; -/** - * The element with a real box to anchor the tooltip to. The wrapper is - * `display: contents` (no box of its own), so use its content span child, - * falling back further for block marks. - */ -const getReferenceElement = (wrapper: Element): Element => { - const content = wrapper.firstElementChild ?? wrapper; - const rect = content.getBoundingClientRect(); - if (rect.width || rect.height) { - return content; - } - return content.firstElementChild ?? content; -}; - -/** - * The box the tooltip anchors to. The wrapper is `display: contents` (no box of - * its own), so use its content span child, falling back further for block marks. - * Exported for the React controller's floating-ui `getBoundingClientRect`. - */ -export const getReferenceRect = (wrapper: Element): DOMRect => - getReferenceElement(wrapper).getBoundingClientRect(); - -/** - * The per-line client rects of the reference element, for floating-ui's - * `inline()` middleware — it needs one rect per line to position off a - * multi-line mark, and virtual elements don't get a default `getClientRects`. - */ -export const getReferenceClientRects = (wrapper: Element): DOMRectList => - getReferenceElement(wrapper).getClientRects(); - /** * State for the currently-hovered suggestion mark's tooltip (`undefined` when * none). The extension computes it; a React controller renders + positions it * (see `AttributionTooltipController`). */ -export type AttributionTooltipState = { +export type AttributionChange = + | { + // `change`: a preview (e.g. a diagram) can't show what was inserted or + // deleted in its hidden source, only that it changed. + modificationType: "insert" | "delete" | "change"; + format?: never; + attributes?: never; + } + | { modificationType: "format"; format?: string[]; attributes?: never } + | { modificationType: "attrs"; attributes: string[]; format?: never }; + +export type AttributionTooltipState = AttributionChange & { /** The wrapper element the tooltip anchors to (floating-ui reference). */ anchor: HTMLElement; + /** Visible surface when its attribution mark covers separately rendered source. */ + reference?: Element; /** Per-user background color, resolved from the user store (default path). */ color: string; - /** The kind of change — `format` is the modification mark. */ - modificationType: "insert" | "delete" | "format"; /** Whether the mark wraps inline content or a whole block. */ contentType: "inline-content" | "block"; - /** Resolved usernames (falls back to raw ids), for custom renderers. */ - users: string[]; /** - * The changed format keys (e.g. `["bold", "italic"]`), present only for - * `format` marks. This is the raw change context — the view layer turns it - * into a localized label via its `formatChangeLabel`. + * Resolved usernames (falls back to raw ids), for custom renderers. Empty + * when the change has no named author, e.g. in a version diff. */ - format?: string[]; + users: string[]; /** * Class name from the `getAttributionMarkClassName` callback (override path). * When present, the tooltip applies this and skips the inline `color`. @@ -133,6 +116,7 @@ export type AttributionTooltipState = { */ export const AttributionExtension = createExtension( ({ + editor, options, }: ExtensionOptions< | { @@ -154,9 +138,6 @@ export const AttributionExtension = createExtension( // over existing text and `tr.changedRange()` would miss. const loadChangedUsers = (tr: Transaction) => { const ranges = getChangedRanges(tr); - if (ranges.length === 0) { - return; - } // Most changes are local (often several steps in one small span), so scan a // single range spanning all of them rather than each range individually. let from = Infinity; @@ -167,19 +148,31 @@ export const AttributionExtension = createExtension( } const ids = new Set(); - tr.doc.nodesBetween(from, to, (node) => { - for (const mark of node.marks) { - if ( - ATTRIBUTION_MARK_TYPES[ - mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES - ] - ) { - const userIds = mark.attrs["userIds"] as string[] | null; - userIds?.forEach((id) => ids.add(id)); - } + // AddNodeMarkStep has an empty position map, so getChangedRanges cannot + // locate its node. Load its authors directly from the added mark. + for (const step of tr.steps) { + if ( + step instanceof AddNodeMarkStep && + step.mark.type.name in ATTRIBUTION_MARK_TYPES + ) { + getAttributionUserIds(step.mark).forEach((id) => ids.add(id)); } - return true; - }); + } + if (ranges.length > 0) { + tr.doc.nodesBetween(from, to, (node) => { + for (const mark of node.marks) { + if ( + ATTRIBUTION_MARK_TYPES[ + mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES + ] + ) { + const userIds = getAttributionUserIds(mark); + userIds?.forEach((id) => ids.add(id)); + } + } + return true; + }); + } if (ids.size > 0) { void userStore.loadUsers(Array.from(ids)); } @@ -213,9 +206,10 @@ export const AttributionExtension = createExtension( const syncRootVars = () => { for (const [id, user] of userStore.store.state) { const { light, dark } = userColorVarNames(id); - if (user.color && user.colorLight) { - dom.style.setProperty(light, user.colorLight); - dom.style.setProperty(dark, user.color); + const colors = userMarkColors(user); + if (colors) { + dom.style.setProperty(light, colors.light); + dom.style.setProperty(dark, colors.dark); } else { dom.style.removeProperty(light); dom.style.removeProperty(dark); @@ -229,35 +223,52 @@ export const AttributionExtension = createExtension( // The mark's authors as usernames, falling back to the raw id when not // cached (`getUser` is cache-only; ids load on hover, see `onPointerOver`). + // A user with an empty name only colors its marks (a version diff's + // synthetic author), so it isn't listed. const usersLabelArray = (userIdsJSON: string | undefined): string[] => - parseUserIds(userIdsJSON).map( - (id) => userStore.getUser(id)?.username ?? id, - ); + parseUserIds(userIdsJSON) + .map((id) => userStore.getUser(id)?.username ?? id) + .filter((username) => username !== ""); - // A stable identity string for a wrapper (empty if unattributed), used to - // (a) test whether a mark is attributed and (b) group adjacent marks with - // the *same* attribution under one tooltip. It's an internal grouping key, - // not the displayed text — that's composed in the view from `users` and - // the format label — so it's built from raw `data-*` (ids + format keys) - // and stays free of i18n/username resolution. + // A stable identity string for a mark wrapper, used to group nested marks + // with the *same* change under one tooltip. Every wrapper is a change; + // its author may be unknown (e.g. content removed along with a + // concurrently deleted block). It's an internal grouping key, not the + // displayed text, so it's built from raw `data-*` and stays free of + // i18n/username resolution. const attributionIdentity = (wrapper: HTMLElement) => { const ids = parseUserIds(wrapper.dataset["userIds"]); - if (ids.length === 0) { - return ""; - } const format = parseFormatKeys(wrapper.dataset["format"]); - return `${format.join(",")}:${ids.join(",")}`; + return `${wrapper.tagName}:${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`; }; - // Build the tooltip state from a wrapper's `data-*` attributes. - const buildState = (anchor: HTMLElement): AttributionTooltipState => { - const isModification = anchor.dataset["format"] !== undefined; - const modificationType: AttributionTooltipState["modificationType"] = - isModification - ? "format" - : anchor.tagName === "INS" - ? "insert" - : "delete"; + // Build the tooltip state from a wrapper's `data-*` attributes. A + // `preview` is the rendered preview the change was hovered through. + const buildState = ( + anchor: HTMLElement, + preview?: Element, + ): AttributionTooltipState => { + const markChange: AttributionChange & { + modificationType: AttributionMarkStyleInfo["modificationType"]; + } = + anchor.dataset["attributes"] !== undefined + ? { + modificationType: "attrs", + attributes: parseFormatKeys(anchor.dataset["attributes"]), + } + : anchor.dataset["format"] !== undefined + ? { + modificationType: "format", + format: parseFormatKeys(anchor.dataset["format"]), + } + : { + modificationType: + anchor.tagName === "INS" ? "insert" : "delete", + }; + const { modificationType } = markChange; + const change: AttributionChange = preview + ? { modificationType: "change" } + : markChange; const contentType: AttributionTooltipState["contentType"] = anchor.dataset["inline"] === "false" ? "block" : "inline-content"; @@ -269,16 +280,14 @@ export const AttributionExtension = createExtension( userStore, parseUserIds(anchor.dataset["userIds"]), ).dark, - modificationType, + ...change, contentType, users: usersLabelArray(anchor.dataset["userIds"]), - format: isModification - ? parseFormatKeys(anchor.dataset["format"]) - : undefined, className: resolveAttributionMarkClassName( getAttributionMarkClassName?.({ contentType, modificationType }), "tooltip", ), + ...(preview ? { reference: preview } : {}), }; }; @@ -290,27 +299,58 @@ export const AttributionExtension = createExtension( store.setState(undefined); }; - // The innermost attributed mark at or above `el`, skipping unattributed - // wrappers so an attributed ancestor still wins. - const innermostAttributed = ( - el: Element | null, - ): HTMLElement | undefined => { - while (el) { - const wrapper = el.closest(ATTRIBUTION_MARK_SELECTOR); - if (!wrapper) { - return undefined; - } - if (attributionIdentity(wrapper)) { - return wrapper; - } - el = wrapper.parentElement; + const nodeAttribution = ( + target: Element, + ): { mark: HTMLElement; preview: Element } | undefined => { + if (!dom.contains(target)) { + return undefined; } - return undefined; + const view = editor.prosemirrorView; + const $pos = view.state.doc.resolve(view.posAtDOM(target, 0)); + if ($pos.depth === 0) { + return undefined; + } + const owner = view.nodeDOM($pos.before($pos.depth)); + if (!(owner instanceof Element) || !owner.contains(target)) { + return undefined; + } + const contentDOM = view.domAtPos($pos.start()).node; + if (contentDOM.contains(target) || target.contains(contentDOM)) { + // Editable content already shows inline attribution. Unchanged text + // and the containing block must not inherit a sibling's change. + return undefined; + } + // A separate rendered preview may hide its attributed source. Only + // hovering that surface represents a change within the whole node, + // not other controls like a checkbox, whose text is on screen. + const name = $pos.parent.type.name; + const hasPreview = + editor.schema.blockSpecs[name]?.implementation?.meta?.hasPreview || + editor.schema.inlineContentSpecs[name]?.implementation?.meta + ?.hasPreview; + if ( + !hasPreview && + contentDOM instanceof Element && + contentDOM.getClientRects().length > 0 + ) { + return undefined; + } + const mark = owner.querySelector( + ATTRIBUTION_MARK_SELECTOR, + ); + return mark ? { mark, preview: owner } : undefined; }; const onPointerOver = (event: Event) => { const target = event.target instanceof Element ? event.target : null; - const innermost = innermostAttributed(target); + const hoveredMark = + target && dom.contains(target) + ? (target.closest(ATTRIBUTION_MARK_SELECTOR) ?? + undefined) + : undefined; + const fallback = + target && !hoveredMark ? nodeAttribution(target) : undefined; + const innermost = hoveredMark ?? fallback?.mark; if (!innermost) { // Not over an attributed mark — drop the current tooltip. hideTooltip(); @@ -319,8 +359,7 @@ export const AttributionExtension = createExtension( const identity = attributionIdentity(innermost); // Anchor on the outermost ancestor with the *same* attribution so one - // tooltip covers the whole region; a differently-attributed ancestor - // breaks the chain, and unattributed ones are climbed past. + // tooltip covers the whole region; a different ancestor breaks the chain. let anchor = innermost; let el: Element | null = innermost.parentElement; while (el) { @@ -328,31 +367,35 @@ export const AttributionExtension = createExtension( if (!ancestor) { break; } - const ancestorIdentity = attributionIdentity(ancestor); - if (ancestorIdentity === identity) { - anchor = ancestor; - } else if (ancestorIdentity) { + if (attributionIdentity(ancestor) !== identity) { break; } + anchor = ancestor; el = ancestor.parentElement; } - if (activeAnchor === anchor) { + if ( + activeAnchor === anchor && + store.state?.reference === fallback?.preview + ) { return; } activeAnchor = anchor; - store.setState(buildState(anchor)); + store.setState(buildState(anchor, fallback?.preview)); // First hover renders raw ids (cache-only); load the authors and refresh // the resolved usernames once loaded, if this mark is still active. const ids = parseUserIds(anchor.dataset["userIds"]); if (ids.length > 0) { void userStore.loadUsers(ids).then(() => { - if (activeAnchor !== anchor) { + if ( + activeAnchor !== anchor || + store.state?.reference !== fallback?.preview + ) { return; } - store.setState(buildState(anchor)); + store.setState(buildState(anchor, fallback?.preview)); }); } }; diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts index 968193b2bd..d248877847 100644 --- a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts +++ b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { colorsForUserIds } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; import { DiffVersioningExtension } from "./DiffVersioningExtension.js"; @@ -12,11 +13,16 @@ import { DiffVersioningExtension } from "./DiffVersioningExtension.js"; // Helpers // --------------------------------------------------------------------------- +const mounts: HTMLElement[] = []; + function createDiffEditor() { const editor = BlockNoteEditor.create({ extensions: [DiffVersioningExtension()], }); - editor.mount(document.createElement("div")); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mounts.push(mount); + editor.mount(mount); return editor; } @@ -86,6 +92,9 @@ describe("DiffVersioningExtension", () => { afterEach(() => { editor.unmount(); + for (const mount of mounts.splice(0)) { + mount.remove(); + } }); it("registers the y-attributed-* marks into the schema", () => { @@ -131,38 +140,78 @@ describe("DiffVersioningExtension", () => { expect(unchanged).toContain("brown fox"); }); - it("attributes the diff to the version author id (userIds on the marks)", () => { + it("attributes every change to one synthetic author (userIds on the marks)", () => { const baseline = blocksFromText("hello world"); const target = blocksFromText("hello there world"); const diff = editor.getExtension(DiffVersioningExtension)!; - diff.renderDiff(target, baseline, "My version"); + diff.renderDiff(target, baseline); - const attributed = collectAttributedText(editor); - const insertUserIds = attributed + const userIds = collectAttributedText(editor) .flatMap((t) => t.marks) - .filter(([n]) => n === "y-attributed-insert") .flatMap(([, ids]) => ids); - // A version diff has one synthetic author (the version). Its id encodes the - // version label, so the tooltip resolves to that name. - expect(insertUserIds).toContain("version:My version"); + // A version diff has no real authors: one synthetic id only colors the marks. + expect(userIds.length).toBeGreaterThan(0); + expect(new Set(userIds).size).toBe(1); }); - it("resolves the diff author to the version's name (tooltip label)", async () => { + it("reports no authors when hovering a change", () => { + const baseline = blocksFromText("hello world"); + const target = blocksFromText("hello new world"); + + editor.getExtension(DiffVersioningExtension)!.renderDiff(target, baseline); + + editor.prosemirrorView.dom + .querySelector("ins[data-user-ids]")! + .dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "insert", + users: [], + }); + }); + + it("colors the diff author with the palette's blue, tint included", async () => { const baseline = blocksFromText("hello world"); const target = blocksFromText("hello brave new world"); const diff = editor.getExtension(DiffVersioningExtension)!; - diff.renderDiff(target, baseline, "Draft 3"); + diff.renderDiff(target, baseline); - // The version name is surfaced by resolving the marks' author id through the - // composed AttributionExtension's user store — this is what the hover tooltip - // shows ("…by {name}"). const attribution = editor.getExtension(AttributionExtension)!; - const authorId = "version:Draft 3"; + const authorId = collectAttributedText(editor) + .flatMap((t) => t.marks) + .flatMap(([, ids]) => ids)[0]!; await attribution.userStore.loadUsers([authorId]); - expect(attribution.userStore.getUser(authorId)?.username).toBe("Draft 3"); + + // Both halves are set, so the marks and their tooltip use the tuned pair + // rather than a tint derived from the saturated colour. + expect(colorsForUserIds(attribution.userStore, [authorId])).toEqual({ + light: "#c9dcff", + dark: "#1e4fb0", + }); + }); + + it("reads a plain block's content as the newer version's text", () => { + function codeBlock(code: string) { + const e = BlockNoteEditor.create(); + e.replaceBlocks(e.document, [ + { id: "code", type: "codeBlock", content: code }, + ]); + return e.document; + } + + editor + .getExtension(DiffVersioningExtension)! + .renderDiff(codeBlock("y^3"), codeBlock("x^2")); + + // Previews (math, diagrams) render from this text, so it must not merge + // the deleted source with its replacement ("xy^23"). + expect(editor.document[0].content).toEqual([ + { type: "text", text: "y^3", styles: {} }, + ]); }); it("produces no attribution marks when the docs are identical", () => { @@ -177,7 +226,7 @@ describe("DiffVersioningExtension", () => { ); }); - it("clearDiff restores plain content with no attribution marks", () => { + it("replacing the rendered blocks drops the attribution marks", () => { const baseline = blocksFromText("first version"); const target = blocksFromText("second version"); const restore = blocksFromText("live document"); @@ -186,7 +235,7 @@ describe("DiffVersioningExtension", () => { diff.renderDiff(target, baseline); expect(attributionMarkNames(editor).size).toBeGreaterThan(0); - diff.clearDiff(restore); + editor.replaceBlocks(editor.document, restore); expect(attributionMarkNames(editor).size).toBe(0); expect(editor.prosemirrorState.doc.textContent).toBe("live document"); }); diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.ts b/packages/core/src/y/extensions/DiffVersioningExtension.ts index 651a11a205..5fabb6dba2 100644 --- a/packages/core/src/y/extensions/DiffVersioningExtension.ts +++ b/packages/core/src/y/extensions/DiffVersioningExtension.ts @@ -4,33 +4,27 @@ import * as Y from "@y/y"; import type { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; -import type { User } from "../../user/index.js"; +import { createUserStore, type User } from "../../user/index.js"; import { _blocksToProsemirrorNode, docDiffToDelta, findTypeInOtherYdoc, - getProseMirrorTrFromYFragment, + yNodeToTransaction, } from "../utils.js"; import { AttributionExtension } from "./AttributionExtension.js"; import type { GetAttributionMarkClassName } from "./YAttributionMarks.js"; /** - * A version diff has a single "author" — the version that introduced the changes - * — not a real user, so all `y-attributed-*` marks carry one synthetic id. That - * id is derived from the version's label (see {@link diffAuthorId}) so it's stable - * per version: the user store caches resolved users by id, so a per-label id keeps - * each version's tooltip showing its own name instead of a stale cached one. + * A version diff has no real authors, so all `y-attributed-*` marks carry one + * synthetic id. Its user has an empty name: it only gives the marks their color, + * and the tooltip names just the kind of change, because a diff can't tell which + * intermediate version introduced it. */ -const DIFF_AUTHOR_ID_PREFIX = "version:"; +const DIFF_AUTHOR_ID = "blocknote:version-diff"; -/** The synthetic author id for a given version label. */ -const diffAuthorId = (label: string) => DIFF_AUTHOR_ID_PREFIX + label; - -/** Fallback label used when a diff is rendered without a version name. */ -const DEFAULT_DIFF_LABEL = "This version"; - -/** Color used for the version diff marks. */ -const DIFF_AUTHOR_COLOR = "#4363d8"; +/** Colors used for the version diff marks — the palette's blue. */ +const DIFF_AUTHOR_COLOR = "#1e4fb0"; +const DIFF_AUTHOR_COLOR_LIGHT = "#c9dcff"; export type DiffVersioningExtensionOptions = { /** @@ -46,13 +40,13 @@ export type DiffVersioningExtensionOptions = { /** * Records the author of each transaction on `doc` into a mutable - * {@link Y.Attributions}, so the resulting attribution marks carry a non-empty - * `userIds` (and therefore resolve to a color/name). The listener must be + * {@link Y.ContentMap}, so the resulting attribution marks carry a non-empty + * `userIds` (and therefore resolve to a color). The listener must be * attached *before* the attributed transaction runs. Mirrors the store used by * the suggestion gallery example (`createAttributionStore`). */ -function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions { - const attrs = new Y.Attributions(); +function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.ContentMap { + const attrs = Y.createContentMap(); doc.on("beforeObserverCalls", (tr) => { if (!tr.insertSet.isEmpty()) { Y.insertIntoIdMap( @@ -83,7 +77,7 @@ function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions { * * It composes {@link AttributionExtension} (which registers the attribution * marks and drives their colors + hover tooltips from a user store), and adds - * the {@link renderDiff} / {@link clearDiff} capability. + * the {@link renderDiff} capability. * * Registering this extension is what makes non-collaborative versioning * (`inMemoryVersioning`) capable of showing diffs: the in-memory preview @@ -112,117 +106,95 @@ export const DiffVersioningExtension = createExtension( editor: BlockNoteEditor; }) => { const color = options?.color ?? DIFF_AUTHOR_COLOR; - - // Resolve a synthetic author id back to its version label. The id encodes - // the label (`version: