diff --git a/AGENTS.md b/AGENTS.md index ee34d9817e..8b992c6744 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,17 @@ All commands below are listed under `package.json` in the project root. See `vit - `vp install`: Installs dependencies. - `vp run dev`: Starts the dev server on port 5173. - `vp run check`: Checks for linting and formatting issues across the project and attempt resolve issues automatically. +- `vp run lint`: Checks for linting & typee-check issues across the project and attempt resolve issues automatically. DO NOT USE `tsc`, or `pretter`, only lint +- `vp run format`: Checks for formatting issues across the project and attempt resolve issues automatically. DO NOT USE `tsc`, or `pretter`, only format - `vp run build`: Builds the project. - `vp run preview`: Previews the build on port 3000. - `vp run test`: Runs unit tests. Append with `-u` to update snapshots. Append with a file name to target only that file. + - To run individual unit tests, use `vp run test `. For example, `vp run test packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts`. - `vp run e2e`: Runs end-to-end tests. Append with a file name to target only that file. - `vp run e2e:updateSnaps`: Runs end-to-end tests & updates snapshots. Append with a file name to target only that file. -- `vp help`: Prints a list of all availabel commands. +- `vp help`: Prints a list of all available commands. + +ONLY USE `vp` or `pnpm`, never `npm` or `yarn`. `vpx` can do what `pnpx` does # Common Entry Points @@ -27,4 +32,4 @@ When writing a new feature, bug fix, or other modification, it may not be immedi # Additional Notes -- Do not create git commits. +- Do not create git commits, unless asked for directly, and do not add Co-Authored-By lines to commits. diff --git a/docs/content/docs/features/collaboration/comments.mdx b/docs/content/docs/features/collaboration/comments.mdx index cf53ad1a93..b5602fc1ef 100644 --- a/docs/content/docs/features/collaboration/comments.mdx +++ b/docs/content/docs/features/collaboration/comments.mdx @@ -10,28 +10,36 @@ BlockNote supports Comments, Comment Threads (replies) and emoji reactions out o To enable comments in your editor, you need to: - Create an instance of the `CommentsExtension` and pass it to the `extensions` editor option. -- Pass `resolveUsers` to your `CommentsExtension` instance, so it can retrieve and display user information (names and avatars). +- Create a user store with `createUserStore(resolveUsers)` and pass it to your `CommentsExtension` instance, so it can retrieve and display user information (names and avatars). Passing the store (rather than `resolveUsers` directly) lets you share a single de-duped user cache with collaboration by also passing it to the `collaboration` options. - Provide a `ThreadStore` to your `CommentsExtension` instance, so it can store and retrieve comment threads. - Enable real-time collaboration (see [Real-time collaboration](/docs/features/collaboration)) - Optionally provide a schema for comments and comment editors to use. If left undefined, they will use the [default comment editor schema](https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/Comments/defaultCommentEditorSchema.ts). See [here](/docs/features/custom-schemas) to find out more about custom schemas. ```tsx +import { createUserStore } from "@blocknote/core"; import { withCollaboration } from "@blocknote/core/yjs"; +// Return user information for the given userIds (see below). +const userStore = createUserStore(async (userIds: string[]) => { ... }); + const editor = useCreateBlockNote( withCollaboration({ extensions: [ CommentsExtension({ // See below. threadStore: ..., - // Return user information for the given userIds (see below). - resolveUsers: async (userIds: string[]) => { ... }, + // A resolver callback or a user store. Pass the shared store so users + // are resolved once across comments and collaboration. + resolveUsers: userStore, // Optional, can be left undefined schema: BlockNoteSchema.create(...) }), ... ], collaboration: { + // Share the same user store so users are resolved once. `resolveUsers` + // accepts either a resolver callback or a pre-built user store. + resolveUsers: userStore, // See real-time collaboration docs ... }, diff --git a/docs/package.json b/docs/package.json index 5797d7488a..7b2d968e2b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,7 +10,7 @@ "build:site": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=6144' next build", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", - "postinstall": "fumadocs-mdx", + "postinstall": "[ -n \"$SKIP_DOCS_POSTINSTALL\" ] || fumadocs-mdx", "lint": "vp lint", "init-db": "pnpx @better-auth/cli migrate" }, @@ -96,7 +96,14 @@ "tailwind-merge": "^3.4.0", "y-partykit": "^0.0.25", "yjs": "^13.6.27", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.17", + "@y/prosemirror": "^2.0.0-4", + "@floating-ui/react": "^0.27.18", + "lib0": "1.0.0-rc.13", + "y-websocket": "^2.1.0" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/e2e-vitest-browser-migration.md b/e2e-vitest-browser-migration.md deleted file mode 100644 index 9006b85c5f..0000000000 --- a/e2e-vitest-browser-migration.md +++ /dev/null @@ -1,237 +0,0 @@ -# Migrate the e2e suite from Playwright to Vitest Browser Mode - -## Context - -BlockNote is adopting **Vite Plus** (the `vp` CLI), which bundles **Vitest 4.1.5 Browser Mode** with a built-in Playwright provider (`@voidzero-dev/vite-plus-test`). We want the e2e suite (`tests/src/end-to-end`, 24 files / ~136 tests) to run under Vitest Browser Mode instead of standalone Playwright, so the whole test toolchain is unified under `vp test` (no separate preview server, no separate Playwright config/runner). - -The fundamental shift: **today each test navigates to a built preview page** (`page.goto("http://localhost:3000/basic/testing?hideMenu")`), which requires building + serving the `playground` app on port 3000. **Vitest Browser Mode runs the test file _inside the browser_ and serves the test plus everything it imports through its own Vite dev server.** So instead of navigating to an example, each test **imports that example's `App` component and mounts it** — no preview server at all. - -We are also removing the abandoned Playwright component-testing experiment (`tests/src/component`, the external copy/paste tests are explicitly flagged as not-yet-correct). - -Decisions confirmed with the user: **full migration** (all 24 files); **migrate the 42 PNG visual snapshots to Vitest's `toMatchScreenshot`** (regenerate baselines); **keep all three browsers** (chromium, firefox, webkit) with the existing per-browser skips. - ---- - -## The core mechanism: mounting instead of navigating - -Each example's `examples///src/App.tsx` is a default-exported React component (`export default function App()`). The playground already loads these dynamically via `import.meta.glob` ([playground/src/main.tsx:22,119-130](playground/src/main.tsx#L22)). We do the same in tests, but with a static import + synchronous render: - -```tsx -import { render } from "vitest-browser-react"; -import App from "../../../../examples/01-basic/testing/src/App.js"; - -beforeEach(() => { - render(); -}); -``` - -- `window.ProseMirror` (used by `getDoc`) is set by `useCreateBlockNote` ([useCreateBlockNote.tsx:30-33](packages/react/src/hooks/useCreateBlockNote.tsx#L30)), so it works for any mounted example. -- `?hideMenu` only hides the _playground_ shell ([playground/src/main.tsx:54,66](playground/src/main.tsx#L54)); mounting `App` directly means there is no shell, so the param is dropped entirely. -- The example→folder mapping (URL slug strips the numeric prefix), to replace every URL constant in [tests/src/utils/const.ts](tests/src/utils/const.ts): - -| Old constant (slug) | Example `App` to import | -| --------------------------------------------------------------------- | ---------------------------------------------------- | -| `BASE_URL` `/basic/testing` | `examples/01-basic/testing` | -| `SHADCN_URL` `/basic/shadcn` | `examples/01-basic/09-shadcn` | -| `ARIAKIT_URL` `/basic/ariakit` | `examples/01-basic/08-ariakit` | -| `MULTI_COLUMN_URL` `/basic/multi-column` | `examples/01-basic/03-multi-column` | -| `BASIC_BLOCKS_URL` `/basic/default-blocks` | `examples/01-basic/04-default-blocks` | -| `NO_TRAILING_BLOCK_URL` `/basic/no-trailing-block` | `examples/01-basic/17-no-trailing-block` | -| `AI_URL` `/ai/minimal` | `examples/09-ai/01-minimal` | -| `STATIC_URL` `/backend/rendering-static-documents` | `examples/02-backend/04-rendering-static-documents` | -| `BASIC_BLOCKS_STATIC_URL` `/interoperability/static-html-render` | `examples/05-interoperability/10-static-html-render` | -| `CUSTOM_BLOCKS_REACT_URL` `/custom-schema/react-custom-blocks` | `examples/06-custom-schema/react-custom-blocks` | -| `ALERT_BLOCK_URL` `/custom-schema/alert-block` | `examples/06-custom-schema/01-alert-block` | -| `NON_EDITABLE_BLOCK_URL` `/custom-schema/non-editable-block` | `examples/06-custom-schema/08-non-editable-block` | -| `PDF_FILE_BLOCK_URL` `/custom-schema/pdf-file-block` | `examples/06-custom-schema/04-pdf-file-block` | -| `COMMENTS_URL` `/collaboration/comments-testing` | `examples/07-collaboration/09-comments-testing` | -| `CUSTOM_BLOCKS_VANILLA_URL` `/vanilla-js/react-vanilla-custom-blocks` | `examples/vanilla-js/react-vanilla-custom-blocks` | - -> **tsconfig note:** statically importing `examples/**/App.tsx` pulls example sources into the tests' `tsc` build task ([tests/vite.config.ts:9-17](tests/vite.config.ts#L9)). Validate that the tests `tsconfig` `include`/references cover these (or add an `@examples/*` path alias). If type friction is excessive, fall back to the playground's `import.meta.glob(..., { import: "default" })` pattern in a small `loadExampleApp` helper. - ---- - -## New dependencies (`tests/package.json`) - -- **`vitest-browser-react`** — provides `render` (+ auto-cleanup between tests). Required; Vite Plus bundles the runner + Playwright provider but not a framework render helper. Use a Vitest-4-compatible version (add via the workspace `catalog:` like `vite-plus`). -- **`playwright`** — add explicitly. The provider runs `await import('playwright')` and bare `playwright` is **not** currently resolvable (only `@playwright/test` is). Pin to the existing `1.60.0`. -- **Remove** `@playwright/experimental-ct-react`. `@playwright/test` can also be removed once nothing imports from it (keep `playwright` only). - ---- - -## Infrastructure changes - -**1. New browser test project — `tests/vite.config.browser.ts`:** - -```ts -import { defineConfig, type UserConfig } from "vite-plus"; -import { playwright } from "vite-plus/test/browser/providers/playwright"; -import { dragAndDropBlock, dragMouse } from "./src/end-to-end/commands"; // see step 3 - -export default defineConfig( - (conf) => - ({ - test: { - name: "e2e", - include: ["./src/end-to-end/**/*.test.ts"], - setupFiles: ["./vitestSetup.browser.ts"], - browser: { - enabled: true, - provider: playwright(), // function call, NOT the string "playwright" - headless: !!process.env.CI, - commands: { dragAndDropBlock, dragMouse }, - expect: { - toMatchScreenshot: { - comparatorName: "pixelmatch", - comparatorOptions: { - threshold: 0.2, - allowedMismatchedPixelRatio: 0.01, - }, - }, - }, - instances: [ - { browser: "chromium" }, - { browser: "firefox" }, - { browser: "webkit" }, - ], - }, - // reuse the dev-time resolve.alias for @blocknote/core + @blocknote/react -> src - }, - resolve: { - /* same alias block as tests/vite.config.ts */ - }, - }) as UserConfig, -); -``` - -**2. Register the project** in the root [vite.config.ts](vite.config.ts) `test.projects` array (alongside `"./tests/vite.config.ts"`): add `"./tests/vite.config.browser.ts"`. The existing `tests/vite.config.ts` jsdom project (unit tests) stays unchanged — browser instances and jsdom cannot share one `test` block, so they remain separate projects. - -**3. Custom mouse commands — `tests/src/end-to-end/commands/` (run in Node, get the real Playwright `page`):** - -Port [tests/src/utils/mouse.ts](tests/src/utils/mouse.ts) logic verbatim into commands that resolve selectors via `frame()` (its `boundingBox()` returns top-level-page coordinates, sidestepping iframe-offset math). Example: - -```ts -import { defineBrowserCommand } from "vite-plus/test/browser/providers/playwright"; - -export const dragAndDropBlock = defineBrowserCommand< - [dragSel: string, dropSel: string, dropAbove: boolean] ->(async ({ frame }, dragSel, dropSel, dropAbove) => { - const f = await frame(); - const drag = f.locator(dragSel); - const box = (await drag.boundingBox())!; - // hover block -> drag handle appears -> drag handle center -> target left/right edge - // (mirrors dragAndDropBlock in mouse.ts using context.page.mouse.move/down/up) -}); -``` - -Augment the `BrowserCommands` interface (in `vitestSetup.browser.ts` or a `.d.ts`) so `server.commands.dragAndDropBlock(...)` is typed. Call from tests via `import { server } from "vite-plus/test/browser/context"`. - -**4. Browser setup file — `tests/vitestSetup.browser.ts`:** sets `window.__TEST_OPTIONS` per test (replacing the Playwright init-script in [tests/src/setup/setupScript.ts](tests/src/setup/setupScript.ts)) and the command type augmentation. Drop the jsdom-only mocks (`ClipboardEvent`/`DragEvent`/`matchMedia`) — the real browser provides them; those stay in the existing [tests/vitestSetup.ts](tests/vitestSetup.ts) for the unit project. - ---- - -## Rewrite shared utilities (`tests/src/utils/`) - -All helpers currently take `page: Page` and use the Playwright API. Rewrite them to use the global Vitest browser context (`page`, `userEvent`, `server` from `vite-plus/test/browser/context`) — they no longer need a `page` argument. The biggest simplification: **the test runs in the browser**, so `window`/`document` are directly accessible. - -| Util | Today (Playwright) | After (Vitest browser) | -| -------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `editor.ts` `getDoc` | `page.evaluateHandle` → `window.ProseMirror.getJSON()` | `(window as any).ProseMirror.getJSON()` directly | -| `editor.ts` `compareDocToSnapshot` | `expect(doc).toMatchSnapshot("x.json")` | `expect(docStr).toMatchFileSnapshot(\`**snapshots**/${name}-${server.browser}.json\`)` (browser name in filename for per-browser baselines) | -| `editor.ts` `focusOnEditor` / `waitForSelectorInEditor` | `page.waitForSelector`/`click` | `await vi.waitFor(() => document.querySelector(".bn-editor"))`; `await userEvent.click(el)`; `await expect.element(...).toBeVisible()` | -| `mouse.ts` | `page.mouse.move/down/up`, `locator.boundingBox()` | thin wrappers calling `server.commands.dragAndDropBlock(...)` / `dragMouse(...)`; coord math moves into the command | -| `copypaste.ts` `copyPaste` | `page.keyboard` Ctrl+C/V | `userEvent.copy()` / `userEvent.paste()` (or `userEvent.keyboard("{Control>}c{/Control}")`) | -| `copypaste.ts` `copyPasteAllExternal(os)` | passed `os` | `server.platform` (external tests removed anyway — see below) | -| `slashmenu.ts` / `draghandle.ts` / `emojipicker.ts` | `page.keyboard.press`, `page.waitForSelector` | `userEvent.keyboard`, `vi.waitFor` / `expect.element(...)` | -| `const.ts` | URL constants + selectors | drop URL constants; keep CSS selector constants + `TYPE_DELAY` | -| pure helpers (`removeAttFromDoc`, `removeClassesFromHTML`, `removeMetaFromHTML`) | — | keep as-is | - ---- - -## Per-test conversion pattern (×24 files) - -Standard transformation per `*.test.ts`: - -- **Imports:** replace `import { expect } from "@playwright/test"` + `import { test } from "../../setup/setupScript.js"` with `import { test, expect, beforeEach, vi } from "vite-plus/test"`, `import { userEvent, page, server } from "vite-plus/test/browser/context"`, `import { render } from "vitest-browser-react"`, and the example `App` import. -- **Setup:** `test.beforeEach(async ({ page }) => { await page.goto(URL) })` → `beforeEach(() => { render(); })`. Drop the `{ page }` fixture from every test signature (use the global `page`). -- **API translation:** - - `page.locator(css)` / queries → `document.querySelector(css)` (in-browser) or `page.elementLocator(el)`; `userEvent`/`expect.element` accept raw `Element`. - - `page.keyboard.insertText/type/press` → `userEvent.keyboard(...)` (testing-library syntax: `{Enter}`, `{Shift>}{ArrowUp}{/Shift}`, etc.). - - `element.boundingBox()` → `el.getBoundingClientRect()`. - - `page.waitForSelector` / `locator.waitFor` → `vi.waitFor(...)` or `await expect.element(locator).toBeVisible()`. - - `page.evaluate(fn)` → run the code directly (already in browser). - - `expect(await el.textContent()).toBe(x)` → `await expect.element(page.elementLocator(el)).toHaveTextContent(x)`. - - file upload (`page.on("filechooser")`, images tests) → `userEvent.upload(inputEl, file)`. - - `test.use({ viewport })` (ai.test.ts) → `page.viewport(w, h)` in `beforeEach`. -- **Per-browser skips:** `test.skip(browserName === "firefox", ...)` → `test.skipIf(server.browser === "firefox")(...)`. Note copy/paste + `cdp()` are Chromium-only, matching existing skips. - ---- - -## Snapshots - -- **JSON doc snapshots (82 uses of `compareDocToSnapshot`):** → `toMatchFileSnapshot`, embedding `server.browser` in the filename for per-browser baselines. Regenerate with the Vitest update flag. -- **PNG visual snapshots (42 uses across 11 files):** `expect(await page.screenshot()).toMatchSnapshot("x.png")` → `await expect.element(locator).toMatchScreenshot("x")`. Vitest auto-appends `-${browserName}-${platform}` to baseline filenames (default dir `__screenshots__//`). All baselines must be regenerated (Vitest screenshots differ from the old Playwright/Docker PNGs). -- **Regeneration must run in Docker** for cross-platform/CI consistency, mirroring the current `test:updateSnaps` Docker flow ([tests/package.json](tests/package.json)) — replace it with a Docker invocation of `vp test --project e2e -u` (headless). Old `*.test.ts-snapshots/` dirs are replaced by the new Vitest snapshot locations. - ---- - -## Removals - -- Delete `tests/src/component/` entirely (incl. `snapshots/`) — the half-baked Playwright CT experiment (external tests carry an explicit "not the output we want" TODO). -- Delete `tests/playwright.config.ts`, `tests/playwright-ct.config.ts`, `tests/src/setup/setupScript.ts`, `tests/src/setup/setupScriptComponent.ts`. -- `tests/package.json` scripts: remove `playwright`, `test-ct`, `test-ct:updateSnaps`; rework `test:updateSnaps` to the Docker `vp test -u` flow. The `test` script (`vp test --run`) now runs jsdom unit + browser e2e projects. -- Root [package.json](package.json): drop the `e2e`/`e2e:updateSnaps` `concurrently "vp run start" + wait-on :3000 + playwright` orchestration — replace with `vp test --project e2e` (no preview server). Keep an `install-playwright` step (`playwright install --with-deps`). - ---- - -## Order of operations - -1. Add deps (`vitest-browser-react`, `playwright`); remove CT dep. -2. Add `tests/vite.config.browser.ts` + register in root `test.projects`; add `vitestSetup.browser.ts`. -3. Implement the `commands/` mouse commands + type augmentation. -4. Rewrite `tests/src/utils/*` to the browser context API. -5. Convert one representative file first (`basics/basics.test.ts`, then `draghandle/draghandle.test.ts` to exercise drag + visual snapshots) to validate the whole chain end-to-end before batch-converting. -6. Convert remaining files; replace URL constants with example imports. -7. Delete component tests + Playwright configs/setup; clean scripts. -8. Regenerate all snapshots (JSON + screenshots) in Docker; commit baselines. - ---- - -## Verification - -- `cd tests && vp test --project e2e` — runs the full e2e suite headless across chromium/firefox/webkit, with **no preview server running**. Confirms mounting + interactions + commands work. -- `vp test` from repo root — both the jsdom unit project and the browser e2e project run and pass. -- Spot-check a visual test (`theming`/`colors`/`slashmenu`) produces a `__screenshots__/...--.png` baseline and re-runs green. -- Spot-check a drag test (`draghandle`) to confirm the `dragAndDropBlock` command drives the real Playwright mouse correctly through the test iframe. -- Confirm the AI test's `window.__TEST_OPTIONS.mockID` is set by the browser setup file before render. -- Grep confirms zero remaining imports from `@playwright/test`, `@playwright/experimental-ct-react`, or `../../setup/setupScript`. - ---- - -## Implementation status (what was actually done) - -**Done & statically verified** (`tsc --noEmit` 0 errors, `vp lint src` 0 errors): - -- Browser project `tests/vite.config.browser.ts` (provider `playwright({ launchOptions: { args: ["--no-sandbox", "--disable-setuid-sandbox"] } })`, 3 instances, `optimizeDeps.exclude: ["fsevents"]`), registered in root `vite.config.ts` `test.projects`. -- `tests/vitestSetup.browser.ts` (seeds `window.__TEST_OPTIONS`). -- `tests/src/end-to-end/commands/playwrightMouse.ts` — the low-level mouse command (resolves the iframe offset via Playwright `frame()`). -- `tests/src/utils/context.ts` — **adapter for this vite-plus build**: the browser-context runtime exports `createUserEvent` (factory), `page`, `cdp`, `locators`, `utils` — _not_ the `userEvent`/`server`/`commands` its `.d.ts` advertises. So `context.ts` builds `userEvent = createUserEvent()`, derives `MOD`/`browserName` from `navigator`, and triggers commands via `window.__vitest_browser_runner__.commands.triggerCommand`. -- All other utils rewritten (`editor`, `mouse`, `copypaste`, `slashmenu`, `emojipicker`, `draghandle`, `keyboard`, `render`, `const`). -- All **24** e2e files converted to `.test.tsx` mounting example `App`s. Component tests, both Playwright configs, `src/setup/*`, and orphaned `*.test.ts-snapshots/` dirs removed. Scripts updated: `tests` → `test:e2e` / `test:e2e:updateSnaps`; root `e2e` → `vp run -r build && cd tests && vp test -c vite.config.browser.ts --run`. -- Shims: `tests/src/examples.d.ts` (`@examples/*` → React component) and `tests/src/vitest-browser.d.ts` (declares the runtime `createUserEvent`). - -**NOT validated at runtime here:** This assistant sandbox cannot complete a Vitest Browser Mode run — even a trivial test fails with _"Browser connection was closed while running tests / Was the page closed unexpectedly?"_ (the headless page dies before the runner initializes). This is environmental; browser tests run fine on the user's machine/CI. **Validate with `cd tests && pnpm test:e2e` (or `pnpm e2e` from root), then `pnpm test:e2e:updateSnaps` to generate baselines** (do snapshot generation in Docker for cross-platform/CI parity). - -**Gotchas worth knowing when validating:** - -- Test discovery only matches `**/*.test.tsx` (recursive glob). Files must live in a subdirectory (not directly under `end-to-end/`) and must not be underscore-prefixed. -- Cold dep-optimization is slow on first run (heavy `@mantine` graph); it caches afterward. Don't mistake a slow first run for a hang. -- Example apps load from **built `dist`** (no source aliases) — that's why `e2e` builds first. - -**Known limitations / TODOs left in code (search `TODO(migration)` / `NOTE:`):** - -1. `theming/theming.test.tsx` — the old `test.use({ colorScheme: "dark" })` has no per-test equivalent in this Vitest browser build; the dark-theme screenshot won't render dark until colorScheme emulation is wired up. -2. `static/static.test.tsx` — `matchPageScreenshot` doesn't expose Playwright's `mask` / `maxDiffPixels`, so the original media-masking + 200px tolerance aren't applied; may be flaky until the helper is extended. -3. `images/images.test.tsx` upload test is `test.skip` (file-path upload → `userEvent.upload` with a placeholder `File`). -4. `comments/comments.test.tsx` popup assertion reimplemented via `vi.spyOn(window, "open")`. -5. `@playwright/test` left as a devDependency (harmless; only `playwright` is required by the provider) — can be removed. diff --git a/examples/07-collaboration/05-comments/src/App.tsx b/examples/07-collaboration/05-comments/src/App.tsx index f0d47ab57b..440aa52084 100644 --- a/examples/07-collaboration/05-comments/src/App.tsx +++ b/examples/07-collaboration/05-comments/src/App.tsx @@ -1,5 +1,6 @@ "use client"; +import { createUserStore } from "@blocknote/core"; import { CommentsExtension, DefaultThreadStoreAuth, @@ -28,6 +29,10 @@ async function resolveUsers(userIds: string[]) { return HARDCODED_USERS.filter((user) => userIds.includes(user.id)); } +// A single user store, shared between the comments and collaboration extensions +// so they use one de-duped cache of resolved users. +const userStore = createUserStore(resolveUsers); + // This follows the Y-Sweet example to setup a collabotive editor // (but of course, you also use other collaboration providers // see the docs for more information) @@ -80,8 +85,9 @@ function Document() { provider, fragment: doc.getXmlFragment("blocknote"), user: { color: getRandomColor(), name: activeUser.username }, + resolveUsers: userStore, }, - extensions: [CommentsExtension({ threadStore, resolveUsers })], + extensions: [CommentsExtension({ threadStore, resolveUsers: userStore })], }), [activeUser, threadStore], ); diff --git a/examples/07-collaboration/05-comments/src/userdata.ts b/examples/07-collaboration/05-comments/src/userdata.ts index c54eaf0f9a..0714cf6e9f 100644 --- a/examples/07-collaboration/05-comments/src/userdata.ts +++ b/examples/07-collaboration/05-comments/src/userdata.ts @@ -1,4 +1,4 @@ -import type { User } from "@blocknote/core/comments"; +import type { User } from "@blocknote/core"; const colors = [ "#958DF1", 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 2828a04230..66d0d43ad3 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx @@ -1,5 +1,6 @@ "use client"; +import { createUserStore } from "@blocknote/core"; import { DefaultThreadStoreAuth, CommentsExtension, @@ -33,6 +34,10 @@ async function resolveUsers(userIds: string[]) { return HARDCODED_USERS.filter((user) => userIds.includes(user.id)); } +// A single user store, shared between the comments and collaboration extensions +// so they use one de-duped cache of resolved users. +const userStore = createUserStore(resolveUsers); + // Sets up Yjs document and PartyKit Yjs provider. const doc = new Y.Doc(); const provider = new YPartyKitProvider( @@ -82,8 +87,9 @@ export default function App() { provider, fragment: doc.getXmlFragment("blocknote"), user: { color: getRandomColor(), name: activeUser.username }, + resolveUsers: userStore, }, - extensions: [CommentsExtension({ threadStore, resolveUsers })], + extensions: [CommentsExtension({ threadStore, resolveUsers: userStore })], }), [activeUser, threadStore], ); diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/userdata.ts b/examples/07-collaboration/06-comments-with-sidebar/src/userdata.ts index c54eaf0f9a..0714cf6e9f 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/userdata.ts +++ b/examples/07-collaboration/06-comments-with-sidebar/src/userdata.ts @@ -1,4 +1,4 @@ -import type { User } from "@blocknote/core/comments"; +import type { User } from "@blocknote/core"; const colors = [ "#958DF1", diff --git a/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json b/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json new file mode 100644 index 0000000000..0b08b7fe94 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": true, + "author": "nperez0111", + "tags": ["Advanced", "Saving/Loading", "Collaboration"], + "dependencies": { + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16", + "@y/prosemirror": "^2.0.0-4", + "@y/websocket": "^4.0.0-rc.2" + } +} diff --git a/examples/07-collaboration/10-suggestion-multi-editor/README.md b/examples/07-collaboration/10-suggestion-multi-editor/README.md new file mode 100644 index 0000000000..426b7dcd1e --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/README.md @@ -0,0 +1,3 @@ +# Suggestions (Experimental) + +In this example, we have 4 editors (2 clients) & 1 in suggestion-view mode & 1 in suggestion-edit mode. To show the experimental support for suggesting content in (@y/y v14) diff --git a/examples/07-collaboration/10-suggestion-multi-editor/index.html b/examples/07-collaboration/10-suggestion-multi-editor/index.html new file mode 100644 index 0000000000..c1c21a9c03 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/index.html @@ -0,0 +1,14 @@ + + + + + Suggestions (Experimental) + + + +
+ + + diff --git a/examples/07-collaboration/10-suggestion-multi-editor/main.tsx b/examples/07-collaboration/10-suggestion-multi-editor/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/10-suggestion-multi-editor/package.json b/examples/07-collaboration/10-suggestion-multi-editor/package.json new file mode 100644 index 0000000000..45e0226a46 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/package.json @@ -0,0 +1,34 @@ +{ + "name": "@blocknote/example-collaboration-suggestion-multi-editor", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16", + "@y/prosemirror": "^2.0.0-4", + "@y/websocket": "^4.0.0-rc.2" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx new file mode 100644 index 0000000000..44845d946d --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx @@ -0,0 +1,246 @@ +import "./style.css"; +import "@blocknote/core/fonts/inter.css"; +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 * as Y from "@y/y"; + +const doc = new Y.Doc(); +const provider = { + awareness: new Awareness(doc), +}; +provider.awareness.setLocalStateField("user", { + name: "Alice", + color: "#30bced", +}); + +const doc2 = new Y.Doc(); +const provider2 = { + awareness: new Awareness(doc2), +}; +provider2.awareness.setLocalStateField("user", { + name: "Bob", + color: "#6eeb83", +}); + +const attrs = new Y.Attributions(); + +// Batch timestamps: reuse the same timestamp for edits from the same user +// within a 10-second window of inactivity. +const BATCH_INTERVAL_MS = 10_000; +const batchTimestamps = new Map(); +const batchTimers = new Map>(); + +function getBatchedTimestamp(userName: string): number { + const existing = batchTimestamps.get(userName); + const now = Date.now(); + + // Clear any pending reset timer + const timer = batchTimers.get(userName); + if (timer) clearTimeout(timer); + + // Start a new batch if none exists or the previous one expired + if (existing == null) { + batchTimestamps.set(userName, now); + } + + // Reset the batch after 10s of inactivity + batchTimers.set( + userName, + setTimeout(() => { + batchTimestamps.delete(userName); + batchTimers.delete(userName); + }, BATCH_INTERVAL_MS), + ); + + return batchTimestamps.get(userName)!; +} + +// Track attributions per user for each doc +function trackAttributions( + trackedDoc: Y.Doc, + userName: string, + attributions: Y.Attributions, +) { + 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), + ]), + ); + }, + ); +} + +// Track local changes on each doc with a distinct user name +trackAttributions(doc, "Alice", attrs); +trackAttributions(doc2, "Bob", attrs); + +const suggestingDoc = new Y.Doc({ isSuggestionDoc: true }); +const suggestingProvider = { + awareness: new Awareness(suggestingDoc), +}; +suggestingProvider.awareness.setLocalStateField("user", { + name: "Charlie", + color: "#ffbc42", +}); +const suggestingAttributionManager = Y.createAttributionManagerFromDiff( + doc, + suggestingDoc, + { attrs }, +); +suggestingAttributionManager.suggestionMode = false; + +const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true }); +const suggestionModeProvider = { + awareness: new Awareness(suggestionModeDoc), +}; +suggestionModeProvider.awareness.setLocalStateField("user", { + name: "Debbie", + color: "#ee6352", +}); +const suggestionModeAttributionManager = Y.createAttributionManagerFromDiff( + doc, + suggestionModeDoc, + { attrs }, +); +suggestionModeAttributionManager.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); + Y.applyUpdate(targetDoc, update); +} + +// Set up two-way sync +function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) { + syncDocs(doc1, doc2); + syncDocs(doc2, doc1); + + doc1.on("update", (update) => { + Y.applyUpdate(doc2, update); + }); + + doc2.on("update", (update) => { + Y.applyUpdate(doc1, update); + }); +} + +setupTwoWaySync(doc, doc2); +setupTwoWaySync(suggestingDoc, suggestionModeDoc); + +function Editor({ + fragment, + provider, + attributionManager, + userName, + userColor, +}: { + fragment: Y.Type; + provider: { awareness?: Awareness }; + attributionManager?: Y.DiffAttributionManager; + userName: string; + userColor: string; +}) { + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment, + provider, + attributionManager, + user: { name: userName, color: userColor }, + }, + }), + ); + + return ; +} + +export default function App() { + // Renders the editor instance using a React component. + return ( +
+
+
+ Client A (Alice) + +
+
+ Client B (Bob) + +
+
+
+
+ View Suggestions (Charlie) + +
+
+ Suggestion Mode (Debbie) + +
+
+
+ ); +} diff --git a/examples/07-collaboration/10-suggestion-multi-editor/src/style.css b/examples/07-collaboration/10-suggestion-multi-editor/src/style.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/07-collaboration/10-suggestion-multi-editor/tsconfig.json b/examples/07-collaboration/10-suggestion-multi-editor/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/10-suggestion-multi-editor/vite-env.d.ts b/examples/07-collaboration/10-suggestion-multi-editor/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json new file mode 100644 index 0000000000..d04a59bb2e --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json @@ -0,0 +1,11 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Advanced", "Development", "Collaboration"], + "dependencies": { + "y-websocket": "^2.1.0", + "yjs": "^13.6.27", + "lib0": "^0.2.99" + } +} diff --git a/examples/07-collaboration/11-versioning-yjs13/README.md b/examples/07-collaboration/11-versioning-yjs13/README.md new file mode 100644 index 0000000000..3482e62a55 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/README.md @@ -0,0 +1,10 @@ +# Local Storage Versioning (yjs v13) + +This example shows how to use the `VersioningExtension` 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. + +**Relevant Docs:** + +- [Editor Setup](/docs/getting-started/editor-setup) +- [Real-time collaboration](/docs/features/collaboration) diff --git a/examples/07-collaboration/11-versioning-yjs13/index.html b/examples/07-collaboration/11-versioning-yjs13/index.html new file mode 100644 index 0000000000..6be2e522ec --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/index.html @@ -0,0 +1,14 @@ + + + + + Local Storage Versioning (yjs v13) + + + +
+ + + diff --git a/examples/07-collaboration/11-versioning-yjs13/main.tsx b/examples/07-collaboration/11-versioning-yjs13/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/11-versioning-yjs13/package.json b/examples/07-collaboration/11-versioning-yjs13/package.json new file mode 100644 index 0000000000..17c75a32f0 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/package.json @@ -0,0 +1,33 @@ +{ + "name": "@blocknote/example-collaboration-versioning-yjs13", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "y-websocket": "^2.1.0", + "yjs": "^13.6.27", + "lib0": "^0.2.99" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx new file mode 100644 index 0000000000..015b90c6ec --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx @@ -0,0 +1,89 @@ +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 { + BlockNoteViewEditor, + useCreateBlockNote, + useExtensionState, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; +import { toBase64, fromBase64 } from "lib0/buffer"; + +import { VersionHistorySidebar } from "./VersionHistorySidebar"; +import "./style.css"; + +const roomName = "blocknote-versioning-yjs-example"; +// 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"); + +// 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)); +} + +// 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, + doc, + { connect: false }, +); +provider.connectBc(); + +export default function App() { + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + provider, + fragment, + 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, + })), + ], + }), + ); + + const { previewedSnapshotId } = useExtensionState(VersioningExtension, { + editor, + }); + + return ( +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx new file mode 100644 index 0000000000..0dfc79dc3f --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx @@ -0,0 +1,24 @@ +import { ComponentProps, useComponentsContext } from "@blocknote/react"; + +// This component is used to display a selection dropdown with a label. By using +// the useComponentsContext hook, we can create it out of existing components +// within the same UI library that `BlockNoteView` uses (Mantine, Ariakit, or +// ShadCN), to match the design of the editor. +export const SettingsSelect = (props: { + label: string; + items: ComponentProps["FormattingToolbar"]["Select"]["items"]; +}) => { + const Components = useComponentsContext()!; + + return ( +
+ +

{props.label + ":"}

+ +
+
+ ); +}; diff --git a/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx b/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx new file mode 100644 index 0000000000..a37cd3b31b --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx @@ -0,0 +1,33 @@ +import { VersioningSidebar } from "@blocknote/react"; +import { useState } from "react"; + +import { SettingsSelect } from "./SettingsSelect"; + +export const VersionHistorySidebar = () => { + const [filter, setFilter] = useState<"named" | "all">("all"); + + return ( +
+
+ setFilter("all"), + isSelected: filter === "all", + }, + { + text: "Named", + icon: null, + onClick: () => setFilter("named"), + isSelected: filter === "named", + }, + ]} + /> +
+ +
+ ); +}; diff --git a/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts new file mode 100644 index 0000000000..d1e6a187af --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts @@ -0,0 +1,171 @@ +import * as Y from "yjs"; +import { toBase64, fromBase64 } from "lib0/buffer"; + +import { + CURRENT_VERSION_ID, + sortSnapshotsNewestFirst, + type VersioningEndpoints, + type VersionSnapshot, +} from "@blocknote/core/extensions"; + +const DEFAULT_STORAGE_KEY = "blocknote-versioning-yjs-snapshots"; + +function getContentsKey(storageKey: string) { + return `${storageKey}-contents`; +} + +function readSnapshots(storageKey: string): VersionSnapshot[] { + return sortSnapshotsNewestFirst( + JSON.parse(localStorage.getItem(storageKey) ?? "[]") as VersionSnapshot[], + ); +} + +function writeSnapshots(storageKey: string, snapshots: VersionSnapshot[]) { + localStorage.setItem( + storageKey, + JSON.stringify(sortSnapshotsNewestFirst(snapshots)), + ); +} + +function readContents(storageKey: string): Record { + return JSON.parse( + localStorage.getItem(getContentsKey(storageKey)) ?? "{}", + ) as Record; +} + +function writeContents(storageKey: string, contents: Record) { + localStorage.setItem(getContentsKey(storageKey), JSON.stringify(contents)); +} + +/** + * Reference {@link VersioningEndpoints} implementation backed by + * `localStorage` for yjs (v13). + * + * Uses `Y.encodeStateAsUpdate` / `Y.applyUpdate` (v1 encoding) instead of the + * v2 encoding used by the `@y/y` (v14) equivalent. + */ +export function createLocalStorageVersioningEndpoints( + 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)]; + }; + + // 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) => { + const snapshot = { + id: crypto.randomUUID(), + name: options?.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!)); + writeContents(storageKey, contents); + + writeSnapshots(storageKey, [snapshot, ...readSnapshots(storageKey)]); + + return snapshot; + }; + + const fetchSnapshotContent: VersioningEndpoints< + Y.XmlFragment, + Uint8Array + >["getContent"] = async (snapshot) => { + const id = String(snapshot.id); + const encoded = readContents(storageKey)[id]; + if (encoded === undefined) { + throw new Error(`Document snapshot ${id} could not be found.`); + } + return 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 rename: VersioningEndpoints< + Y.XmlFragment, + Uint8Array + >["rename"] = async (snapshot, name) => { + const snapshots = readSnapshots(storageKey); + const stored = snapshots.find((s) => s.id === snapshot.id); + if (stored === undefined) { + throw new Error( + `Document snapshot ${String(snapshot.id)} could not be found.`, + ); + } + + stored.name = name; + stored.updatedAt = Date.now(); + writeSnapshots(storageKey, snapshots); + }; + + const remove: VersioningEndpoints< + Y.XmlFragment, + Uint8Array + >["remove"] = async (snapshot) => { + const snapshots = readSnapshots(storageKey); + if (!snapshots.some((s) => s.id === snapshot.id)) { + throw new Error( + `Document snapshot ${String(snapshot.id)} could not be found.`, + ); + } + + // Drop the snapshot metadata and its stored content. + writeSnapshots( + storageKey, + snapshots.filter((s) => s.id !== snapshot.id), + ); + + const contents = readContents(storageKey); + delete contents[String(snapshot.id)]; + writeContents(storageKey, contents); + }; + + return { + list: listSnapshots, + create: createSnapshot, + getContent: fetchSnapshotContent, + restore: restoreSnapshot, + rename, + remove, + }; +} + +/** Default localStorage-backed endpoints using {@link DEFAULT_STORAGE_KEY}. */ +export const localStorageEndpoints = createLocalStorageVersioningEndpoints(); diff --git a/examples/07-collaboration/11-versioning-yjs13/src/style.css b/examples/07-collaboration/11-versioning-yjs13/src/style.css new file mode 100644 index 0000000000..e75d6ef7b8 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/style.css @@ -0,0 +1,141 @@ +.wrapper { + 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 { + flex: 1; + height: calc(100vh - 20px); + min-width: 0; + overflow: auto; +} + +.editor-panel .bn-container { + height: calc(100vh - 20px); + margin: 0; + max-width: none; + padding: 0; +} + +.editor-panel .bn-editor { + height: calc(100vh - 20px); + overflow: auto; +} + +.sidebar-section { + background-color: var(--bn-colors-disabled-background); + display: flex; + flex-direction: column; + height: calc(100vh - 20px); + overflow: auto; + width: 350px; +} + +.sidebar-section .settings { + padding: 8px; +} + +.bn-versioning-sidebar { + flex: 1; + overflow: auto; + padding-inline: 16px; +} + +.settings-select { + display: flex; + gap: 10px; +} + +.settings-select .bn-toolbar { + align-items: center; +} + +.settings-select h2 { + color: var(--bn-colors-menu-text); + margin: 0; + font-size: 12px; + line-height: 12px; + 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; +} + +.dark .bn-versioning-sidebar .bn-snapshot.selected { + background-color: #20242a; + border: 2px solid #23405b; +} diff --git a/examples/07-collaboration/11-versioning-yjs13/tsconfig.json b/examples/07-collaboration/11-versioning-yjs13/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/11-versioning-yjs13/vite-env.d.ts b/examples/07-collaboration/11-versioning-yjs13/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/11-versioning-yjs13/vite.config.ts b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json new file mode 100644 index 0000000000..df85ffb096 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": false, + "author": "nperez0111", + "tags": ["Advanced", "Collaboration"], + "dependencies": { + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.16", + "lib0": "1.0.0-rc.13" + } +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/README.md b/examples/07-collaboration/12-multi-doc-versioning/README.md new file mode 100644 index 0000000000..af4adf48e0 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/README.md @@ -0,0 +1,17 @@ +# YHub Multi-Doc + +This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `VersioningExtension` and Y.js v14. + +**Features:** + +- User picker (per-tab identity via `sessionStorage`) +- 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` +- Open multiple tabs with different users via the `?as=` URL param + +**Relevant Docs:** + +- [Versioning](https://www.blocknotejs.org/docs/collaboration/versioning) +- [Y.js Collaboration](https://www.blocknotejs.org/docs/collaboration) diff --git a/examples/07-collaboration/12-multi-doc-versioning/index.html b/examples/07-collaboration/12-multi-doc-versioning/index.html new file mode 100644 index 0000000000..96b60e220f --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/index.html @@ -0,0 +1,14 @@ + + + + + YHub Multi-Doc + + + +
+ + + diff --git a/examples/07-collaboration/12-multi-doc-versioning/main.tsx b/examples/07-collaboration/12-multi-doc-versioning/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/12-multi-doc-versioning/package.json b/examples/07-collaboration/12-multi-doc-versioning/package.json new file mode 100644 index 0000000000..3d1d97cd87 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/package.json @@ -0,0 +1,34 @@ +{ + "name": "@blocknote/example-collaboration-multi-doc-versioning", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.16", + "lib0": "1.0.0-rc.13" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx new file mode 100644 index 0000000000..ef333116c0 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx @@ -0,0 +1,258 @@ +import { useEffect, useRef, useState } from "react"; + +import "./style.css"; +import { USERS } from "./userdata.js"; +import { useCurrentUser, setCurrentUser } from "./identity.js"; +import { useHashRoute, replaceRoute, navigate } from "./router.js"; +import { useDocIndex } from "./docIndex.js"; +import { generateRandomId } from "./utils.js"; +import { LoginScreen } from "./LoginScreen.js"; +import { DocumentList } from "./DocumentList.js"; +import { DocumentEditor } from "./DocumentEditor.js"; + +export default function App() { + const user = useCurrentUser(); + const segments = useHashRoute(); + + // Route table: + // [] -> if logged in, ensure workspace; else login + // ['login'] -> login screen + // ['w', wsId] -> workspace, no doc selected + // ['w', wsId, docId] -> workspace + doc editor + const [seg0, seg1, seg2] = segments; + + useEffect(() => { + if (user && seg0 !== "w") { + replaceRoute(`/w/${generateRandomId(10)}`); + } + }, [user, seg0]); + + if (!user) { + return ; + } + + if (seg0 !== "w" || !seg1) { + return
Loading...
; + } + + const workspaceId = seg1; + const docId = seg2 || null; + + return ; +} + +function Workspace({ + user, + workspaceId, + docId, +}: { + user: (typeof USERS)[0]; + workspaceId: string; + docId: string | null; +}) { + const index = useDocIndex(); + const activeDoc = docId ? index.docs.find((d) => d.id === docId) : null; + const [copied, setCopied] = useState(false); + + const shareWorkspace = () => { + setCopied(true); + setTimeout(() => setCopied(false), 1800); + const url = window.location.href; + navigator.clipboard?.writeText(url).catch(() => { + window.prompt("Copy this URL to share the workspace", url); + }); + }; + + const signOut = () => { + setCurrentUser(null); + navigate("/"); + }; + + const switchUser = (id: string) => { + if (id === user.id) { + return; + } + setCurrentUser(id); + }; + + return ( +
+
+
+ + + {workspaceId} + + {activeDoc && /} + {activeDoc && ( + {activeDoc.title} + )} +
+
+ + +
+
+
+ + {activeDoc ? ( + index.touch(activeDoc.id)} + /> + ) : ( + 0} + onCreate={() => { + const id = index.create(); + if (id) { + navigate(`/w/${workspaceId}/${id}`); + } + }} + /> + )} +
+
+ ); +} + +function UserMenu({ + user, + onSwitch, + onSignOut, +}: { + user: (typeof USERS)[0]; + onSwitch: (id: string) => void; + onSignOut: () => void; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) { + return undefined; + } + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const pick = (id: string) => { + setOpen(false); + onSwitch(id); + }; + + return ( +
+ + {open && ( +
+
Switch user
+ {USERS.map((u) => { + const isCurrent = u.id === user.id; + return ( + + ); + })} +
+ +
+ )} +
+ ); +} + +function EmptyDocPane({ + hasDocs, + onCreate, +}: { + hasDocs: boolean; + onCreate: () => void; +}) { + return ( +
+
+

+ {hasDocs ? "Pick a document from the sidebar" : "No documents yet"} +

+

+ {hasDocs + ? "Or create a new one to start writing." + : "Create your first document to start writing and collaborating."} +

+ +
+
+ ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/DocumentEditor.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/DocumentEditor.tsx new file mode 100644 index 0000000000..655f9aa518 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/DocumentEditor.tsx @@ -0,0 +1,307 @@ +import "@blocknote/core/fonts/inter.css"; +import { + withCollaboration, + SuggestionsExtension, + createYHubVersioningEndpoints, +} from "@blocknote/core/y"; +import { type User } from "@blocknote/core"; +import { VersioningExtension } from "@blocknote/core/extensions"; +import { + BlockNoteViewEditor, + useCreateBlockNote, + useExtension, + useExtensionState, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useEffect, useMemo, useRef, useState } from "react"; +import * as Y from "@y/y"; +import { fromBase64 } from "lib0/buffer"; +import { WebsocketProvider } from "@y/websocket"; + +import { resolveUsers } from "./userdata.js"; + +import { HistorySidebar } from "./HistorySidebar.js"; + +/** + * DocumentEditor mounts one collaborative editor at a time, keyed by docId. + * Switching documents unmounts + remounts this component (via `key` in App). + */ +export function DocumentEditor({ + workspaceId, + docId, + user, + docTitle, + onTouch, +}: { + workspaceId: string; + docId: string; + user: User; + docTitle: string; + onTouch: () => void; +}) { + const roomName = `${workspaceId}/${docId}`; + + // Stable refs for Y.js resources that persist for this mount + const resourcesRef = useRef<{ + doc: Y.Doc; + suggestionDoc: Y.Doc; + provider: WebsocketProvider; + suggestionProvider: WebsocketProvider; + attributionManager: ReturnType; + versioningEndpoints: ReturnType; + } | null>(null); + + if (!resourcesRef.current) { + const doc = new Y.Doc(); + + // Apply pre-seeded document state if available (one-time) + const docStateKey = `bn-doc-state-${docId}`; + const savedState = localStorage.getItem(docStateKey); + if (savedState) { + Y.applyUpdateV2(doc, fromBase64(savedState)); + localStorage.removeItem(docStateKey); + } + + 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 suggestionProvider = new WebsocketProvider( + `wss://${yhubHost}/ws`, + roomName + "-suggestions", + suggestionDoc, + { + params: { + userid: user.id, + }, + }, + ); + const attributionManager = Y.createAttributionManagerFromDiff( + doc, + suggestionDoc, + ); + + const versioningEndpoints = createYHubVersioningEndpoints({ + baseUrl: `https://${yhubHost}`, + org: workspaceId, + docId, + }); + + resourcesRef.current = { + doc, + suggestionDoc, + provider, + suggestionProvider, + attributionManager, + versioningEndpoints, + }; + } + + const { + doc, + suggestionDoc, + provider, + suggestionProvider, + attributionManager, + versioningEndpoints, + } = resourcesRef.current; + + // Clean up on unmount + useEffect(() => { + return () => { + provider.destroy(); + suggestionProvider.destroy(); + doc.destroy(); + suggestionDoc.destroy(); + }; + }, []); + + // Throttled touch callback for updatedAt + const touchRef = useRef(onTouch); + touchRef.current = onTouch; + const lastTouchRef = useRef(0); + + useEffect(() => { + const scheduleTouch = () => { + const now = Date.now(); + if (now - lastTouchRef.current >= 5000) { + lastTouchRef.current = now; + touchRef.current(); + } + }; + const onUpdate = ( + _u: Uint8Array, + _origin: unknown, + _doc: Y.Doc, + tr: { local: boolean }, + ) => { + if (tr.local) { + scheduleTouch(); + } + }; + doc.on("update", onUpdate); + return () => { + doc.off("update", onUpdate); + }; + }, [doc]); + + // Connection status tracking + const [connStatus, setConnStatus] = useState("connecting"); + useEffect(() => { + const onStatus = (e: { status: string }) => setConnStatus(e.status); + provider.on("status", onStatus); + if (provider.wsconnected) { + setConnStatus("connected"); + } + return () => { + provider.off("status", onStatus); + }; + }, [provider]); + + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + provider, + suggestionDoc, + attributionManager, + fragment: doc.get(), + user: { + color: user.color ?? "#000000", + name: user.username, + id: user.id, + }, + versioningEndpoints, + // Resolves version-author ids (YHub's `by`) to usernames in the history + // sidebar and diff tooltips. + resolveUsers, + }, + }), + ); + + // 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, + }); + + const { enableSuggestions, disableSuggestions, viewSuggestions } = + useExtension(SuggestionsExtension, { editor }); + + const [editingMode, setEditingMode] = useState< + "editing" | "suggestions" | "view-suggestions" + >("editing"); + + // Exit suggestion modes when entering version preview + useEffect(() => { + if (previewedSnapshotId !== undefined && editingMode !== "editing") { + disableSuggestions(); + setEditingMode("editing"); + } + }, [previewedSnapshotId]); + + const modeOptions = useMemo( + () => [ + { value: "editing" as const, label: "Editing" }, + { value: "view-suggestions" as const, label: "Viewing Suggestions" }, + { value: "suggestions" as const, label: "Suggesting" }, + ], + [], + ); + + const [showSidebar, setShowSidebar] = useState(true); + + const changeMode = (next: typeof editingMode) => { + if (next === editingMode) { + return; + } + if (next === "editing") { + disableSuggestions(); + } else if (next === "view-suggestions") { + viewSuggestions(); + } else if (next === "suggestions") { + enableSuggestions(); + } + setEditingMode(next); + }; + + return ( + +
+
+
+
+

{docTitle || "Untitled"}

+
+ {previewedSnapshotId === undefined && ( + + )} + + {connStatus} + + {!showSidebar && ( + + )} +
+
+
+
+ +
+
+ {showSidebar && ( + setShowSidebar(false)} /> + )} +
+
+ ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/DocumentList.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/DocumentList.tsx new file mode 100644 index 0000000000..f76e83d0cb --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/DocumentList.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; + +import type { useDocIndex } from "./docIndex.js"; +import { navigate } from "./router.js"; +import { formatRelative } from "./utils.js"; + +type DocIndex = ReturnType; + +export function DocumentList({ + index, + workspaceId, + activeDocId, +}: { + index: DocIndex; + workspaceId: string; + activeDocId: string | null; +}) { + const [editingId, setEditingId] = useState(null); + const [editingValue, setEditingValue] = useState(""); + + const startEdit = (doc: { id: string; title: string }) => { + setEditingId(doc.id); + setEditingValue(doc.title); + }; + const commitEdit = () => { + if (editingId) { + index.rename(editingId, editingValue.trim() || "Untitled"); + } + setEditingId(null); + setEditingValue(""); + }; + const cancelEdit = () => { + setEditingId(null); + setEditingValue(""); + }; + + const onCreate = () => { + const id = index.create(); + if (id) { + navigate(`/w/${workspaceId}/${id}`); + } + }; + + const onOpen = (id: string) => { + navigate(`/w/${workspaceId}/${id}`); + }; + + const onDelete = (id: string, title: string) => { + if ( + window.confirm(`Delete "${title}"? This can't be undone in the demo.`) + ) { + index.remove(id); + if (activeDocId === id) { + navigate(`/w/${workspaceId}`); + } + } + }; + + return ( + + ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx new file mode 100644 index 0000000000..0c300cbf6e --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/HistorySidebar.tsx @@ -0,0 +1,15 @@ +import { VersioningSidebar } from "@blocknote/react"; + +export function HistorySidebar({ onClose }: { onClose: () => void }) { + return ( + + ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/LoginScreen.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/LoginScreen.tsx new file mode 100644 index 0000000000..7a11c3e68f --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/LoginScreen.tsx @@ -0,0 +1,39 @@ +import { setCurrentUser } from "./identity.js"; +import { navigate } from "./router.js"; +import { USERS } from "./userdata.js"; + +export function LoginScreen({ redirectTo }: { redirectTo: string }) { + const handlePick = (id: string) => { + setCurrentUser(id); + navigate(redirectTo || "/"); + }; + + return ( +
+
+

Welcome

+

+ Pick a user to continue. This is a demo — there are no passwords. +

+
+ {USERS.map((u) => ( + + ))} +
+
+
+ ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts b/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts new file mode 100644 index 0000000000..c5571cf85d --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { generateDocTitle, generateRandomId } from "./utils.js"; + +export type DocEntry = { + id: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +const STORAGE_KEY = "bn-multi-doc-index"; + +function readDocs(): DocEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const docs = JSON.parse(raw) as DocEntry[]; + return docs.sort((a, b) => a.createdAt - b.createdAt); + } catch { + return []; + } +} + +function writeDocs(docs: DocEntry[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(docs)); +} + +/** + * Simple localStorage-backed document index. Provides create, rename, delete, + * and touch (update timestamp) operations. Uses a custom event to sync across + * hook instances within the same tab. + */ +export function useDocIndex() { + const [docs, setDocs] = useState(readDocs); + + // Listen for changes from other calls within the same tab + useEffect(() => { + const handler = () => setDocs(readDocs()); + window.addEventListener("bn-doc-index-change", handler); + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) { + handler(); + } + }); + return () => { + window.removeEventListener("bn-doc-index-change", handler); + }; + }, []); + + const notify = useCallback(() => { + window.dispatchEvent(new Event("bn-doc-index-change")); + }, []); + + const create = useCallback( + (title?: string): string => { + const id = generateRandomId(6); + const now = Date.now(); + const entry: DocEntry = { + id, + title: title ?? generateDocTitle(), + createdAt: now, + updatedAt: now, + }; + const current = readDocs(); + current.push(entry); + writeDocs(current); + notify(); + return id; + }, + [notify], + ); + + const rename = useCallback( + (id: string, title: string) => { + const current = readDocs(); + const entry = current.find((d) => d.id === id); + if (!entry) { + return; + } + entry.title = title; + entry.updatedAt = Date.now(); + writeDocs(current); + notify(); + }, + [notify], + ); + + const remove = useCallback( + (id: string) => { + const current = readDocs().filter((d) => d.id !== id); + writeDocs(current); + // Also clean up versioning data for this doc + try { + localStorage.removeItem(`bn-versioning-${id}`); + localStorage.removeItem(`bn-versioning-${id}-contents`); + } catch { + /* ignore */ + } + notify(); + }, + [notify], + ); + + const touch = useCallback( + (id: string) => { + const current = readDocs(); + const entry = current.find((d) => d.id === id); + if (!entry) { + return; + } + entry.updatedAt = Date.now(); + writeDocs(current); + notify(); + }, + [notify], + ); + + return useMemo( + () => ({ docs, create, rename, remove, touch }), + [docs, create, rename, remove, touch], + ); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/identity.ts b/examples/07-collaboration/12-multi-doc-versioning/src/identity.ts new file mode 100644 index 0000000000..f5777f3abf --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/identity.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; + +import type { User } from "@blocknote/core"; + +import { USERS } from "./userdata.js"; + +const STORAGE_KEY = "bn-multi-doc-user"; + +/** + * Per-tab identity via sessionStorage so two browser tabs can hold different + * users simultaneously. The `?as=` URL param takes precedence and is + * persisted into sessionStorage. + */ +export const getCurrentUser = (): User | null => { + try { + const fromUrl = new URLSearchParams(window.location.search).get("as"); + if (fromUrl && USERS.some((u) => u.id === fromUrl)) { + sessionStorage.setItem(STORAGE_KEY, fromUrl); + return USERS.find((u) => u.id === fromUrl)!; + } + const id = sessionStorage.getItem(STORAGE_KEY); + return USERS.find((u) => u.id === id) ?? null; + } catch { + return null; + } +}; + +export const setCurrentUser = (id: string | null): void => { + try { + if (id) { + sessionStorage.setItem(STORAGE_KEY, id); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + } catch { + /* ignore */ + } + // Keep the ?as= URL param in sync + try { + const url = new URL(window.location.href); + if (url.searchParams.has("as")) { + if (id) { + url.searchParams.set("as", id); + } else { + url.searchParams.delete("as"); + } + window.history.replaceState(null, "", url.toString()); + } + } catch { + /* ignore */ + } + window.dispatchEvent(new Event("bn-identity-change")); +}; + +export const useCurrentUser = (): User | null => { + const [user, setUser] = useState(getCurrentUser); + useEffect(() => { + const handler = () => setUser(getCurrentUser()); + window.addEventListener("bn-identity-change", handler); + return () => window.removeEventListener("bn-identity-change", handler); + }, []); + return user; +}; diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/router.ts b/examples/07-collaboration/12-multi-doc-versioning/src/router.ts new file mode 100644 index 0000000000..634e9034e4 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/router.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +const parse = (): string[] => { + const raw = window.location.hash.replace(/^#\/?/, ""); + const segments = raw.split("?")[0].split("/").filter(Boolean); + return segments; +}; + +export const useHashRoute = (): string[] => { + const [segments, setSegments] = useState(parse); + useEffect(() => { + const handler = () => setSegments(parse()); + window.addEventListener("hashchange", handler); + return () => window.removeEventListener("hashchange", handler); + }, []); + return segments; +}; + +export const navigate = (path: string): void => { + const target = path.startsWith("#") + ? path + : "#" + (path.startsWith("/") ? path : "/" + path); + if (window.location.hash === target) { + return; + } + window.location.hash = target.slice(1); +}; + +export const replaceRoute = (path: string): void => { + const target = path.startsWith("#") + ? path + : "#" + (path.startsWith("/") ? path : "/" + path); + const url = window.location.pathname + window.location.search + target; + window.history.replaceState(null, "", url); + window.dispatchEvent(new HashChangeEvent("hashchange")); +}; diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/style.css b/examples/07-collaboration/12-multi-doc-versioning/src/style.css new file mode 100644 index 0000000000..d1afbabe0e --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/style.css @@ -0,0 +1,795 @@ +/* ===== Theme tokens ===== */ + +:root { + --bg: #ffffff; + --bg-elevated: #f8f9fb; + --bg-inset: #f1f3f6; + --bg-hover: #eef0f4; + --bg-active: #e6e9ef; + --border: #e3e6ec; + --border-strong: #cfd3da; + --text: #1a1d22; + --text-muted: #5b6370; + --text-subtle: #8a909b; + --accent: #2564eb; + --accent-hover: #1d4fc2; + --accent-soft: #eaf0ff; + --danger: #d64545; + --danger-hover: #b13535; + --success: #1e9968; + --shadow-sm: + 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 1px rgba(15, 23, 42, 0.04); + --shadow-md: + 0 4px 10px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-lg: + 0 20px 40px rgba(15, 23, 42, 0.12), 0 4px 10px rgba(15, 23, 42, 0.06); + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --ins-bg: rgba(30, 153, 104, 0.18); + --ins-border: rgba(30, 153, 104, 0.7); + --del-bg: rgba(214, 69, 69, 0.14); + --del-border: rgba(214, 69, 69, 0.75); + --del-text: rgba(214, 69, 69, 0.9); + --mod-bg: rgba(24, 122, 220, 0.16); + --mod-border: rgba(24, 122, 220, 0.7); +} + +[data-mantine-color-scheme="dark"] { + --bg: #15171c; + --bg-elevated: #1b1e24; + --bg-inset: #20242c; + --bg-hover: #262a33; + --bg-active: #2f343e; + --border: #2a2f38; + --border-strong: #3a404b; + --text: #e9ebef; + --text-muted: #9aa2b1; + --text-subtle: #6e7682; + --accent: #5b8cff; + --accent-hover: #769fff; + --accent-soft: #1b2744; + --danger: #e96a6a; + --danger-hover: #f18787; + --success: #3db987; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 10px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.5), 0 4px 10px rgba(0, 0, 0, 0.4); + --ins-bg: rgba(61, 185, 135, 0.28); + --ins-border: rgba(103, 214, 165, 0.85); + --del-bg: rgba(233, 106, 106, 0.24); + --del-border: rgba(240, 160, 160, 0.85); + --del-text: #f0a0a0; + --mod-bg: rgba(91, 140, 255, 0.28); + --mod-border: rgba(166, 190, 255, 0.85); +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +/* ===== Shared primitives ===== */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease, + transform 0.05s ease; + white-space: nowrap; + font-weight: 500; +} +.btn:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-strong); +} +.btn:active:not(:disabled) { + transform: translateY(0.5px); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-sm { + padding: 4px 10px; + font-size: 12.5px; +} +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + color: var(--text-muted); + transition: + background 0.12s ease, + color 0.12s ease; +} +.btn-icon:hover { + background: var(--bg-hover); + color: var(--text); +} +.btn-icon-danger:hover { + background: var(--bg-hover); + color: var(--danger); +} + +/* ===== Login screen ===== */ + +.login-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} +.login-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 48px 40px; + max-width: 420px; + width: 100%; + box-shadow: var(--shadow-lg); +} +.login-title { + margin: 0 0 8px; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.01em; +} +.login-subtitle { + margin: 0 0 28px; + color: var(--text-muted); + font-size: 14px; +} +.login-users { + display: flex; + flex-direction: column; + gap: 10px; +} +.login-user { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + transition: + background 0.12s ease, + border-color 0.12s ease; + text-align: left; +} +.login-user:hover { + background: var(--bg-hover); + border-color: var(--user-color, var(--border-strong)); +} +.login-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; +} +.login-user-name { + font-size: 15px; + font-weight: 500; +} + +/* ===== App shell ===== */ + +.app-shell { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); + min-height: 52px; + flex-shrink: 0; +} +.app-header-left, +.app-header-right { + display: flex; + align-items: center; + gap: 10px; +} +.workspace-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 999px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; + color: var(--text-muted); +} +.workspace-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); +} +.app-header-sep { + color: var(--text-subtle); +} +.app-header-doctitle { + font-weight: 500; + color: var(--text); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu { + position: relative; + display: inline-block; +} +.user-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 6px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease; +} +.user-pill:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} +.user-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 11px; + flex-shrink: 0; +} +.user-name { + font-size: 13px; + font-weight: 500; +} +.user-caret { + font-size: 10px; + color: var(--text-muted); + margin-left: -2px; +} +.user-menu-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 200px; + padding: 6px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 50; + display: flex; + flex-direction: column; + gap: 2px; +} +.user-menu-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + padding: 6px 8px 4px; +} +.user-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: var(--radius-sm); + text-align: left; + font-size: 13px; + color: var(--text); + cursor: pointer; + transition: background 0.1s ease; +} +.user-menu-item:hover { + background: var(--bg-hover); +} +.user-menu-item-name { + flex: 1; + font-weight: 500; +} +.user-menu-check { + color: var(--text-muted); + font-size: 12px; +} +.user-menu-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} +.user-menu-item-signout { + color: var(--text-muted); +} +.user-menu-item-signout:hover { + color: var(--text); +} + +/* Override BlockNote's .bn-container defaults (set by playground) */ +.app-body .bn-container { + margin: 0; + max-width: none; + padding: 0; + height: 100%; +} + +.app-body { + flex: 1; + display: grid; + grid-template-columns: 260px 1fr; + min-height: 0; +} + +/* ===== Document list (left sidebar) ===== */ + +.doc-list { + border-right: 1px solid var(--border); + background: var(--bg-elevated); + overflow-y: auto; + display: flex; + flex-direction: column; +} +.doc-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 8px; + position: sticky; + top: 0; + background: var(--bg-elevated); +} +.doc-list-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-subtle); +} +.doc-list-empty { + padding: 40px 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; + line-height: 1.6; +} +.doc-list-items { + list-style: none; + padding: 4px 6px 8px; + margin: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.doc-list-item { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: var(--radius-sm); + position: relative; +} +.doc-list-item:hover { + background: var(--bg-hover); +} +.doc-list-item.active { + background: var(--bg-active); +} +.doc-list-item-open { + flex: 1; + text-align: left; + padding: 6px 8px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.doc-list-item-title { + font-size: 13.5px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.doc-list-item-meta { + font-size: 11.5px; + color: var(--text-subtle); +} +.doc-list-item-input { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; + outline: none; +} +.doc-list-item-actions { + display: none; + gap: 2px; + padding-right: 4px; +} +.doc-list-item:hover .doc-list-item-actions { + display: inline-flex; +} + +/* ===== Empty doc pane ===== */ + +.doc-empty { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} +.doc-empty-inner { + text-align: center; + padding: 40px; + max-width: 420px; +} +.doc-empty-title { + font-size: 20px; + font-weight: 600; + margin: 0 0 8px; + color: var(--text); +} +.doc-empty-sub { + color: var(--text-muted); + font-size: 14px; + margin: 0 0 20px; +} + +/* ===== Document workspace (editor + history) ===== */ + +.doc-workspace { + display: grid; + grid-template-columns: 1fr 320px; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +/* When the history sidebar is closed, the editor takes the full width. */ +.doc-workspace-no-sidebar { + grid-template-columns: 1fr; +} + +/* "History" button in the document header, shown only while the sidebar is + closed so the user can reopen it. */ +.show-history-button { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12.5px; + font-weight: 500; + border-radius: var(--radius-sm); + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); + cursor: pointer; + white-space: nowrap; + transition: + background 0.12s ease, + border-color 0.12s ease; +} +.show-history-button:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.doc-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: var(--bg); + overflow: hidden; +} + +.doc-main-header { + padding: 14px 24px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.doc-main-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.doc-main-title { + font-size: 18px; + font-weight: 600; + margin: 0; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.doc-main-controls { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.mode-select { + appearance: none; + -webkit-appearance: none; + padding: 6px 28px 6px 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: + background-color 0.12s ease, + border-color 0.12s ease; +} +.mode-select:hover { + background-color: var(--bg-hover); + border-color: var(--border-strong); +} +.mode-select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.doc-status { + font-size: 10.5px; + padding: 1px 7px; + border-radius: 999px; + text-transform: capitalize; + font-weight: 500; + letter-spacing: 0.01em; + background: var(--bg-inset); + color: var(--text-muted); +} +.doc-status-connected { + background: rgba(30, 153, 104, 0.12); + color: var(--success); +} +.doc-status-connecting { + background: rgba(255, 188, 66, 0.18); + color: #b97c00; +} +.doc-status-disconnected { + background: rgba(214, 69, 69, 0.12); + color: var(--danger); +} + +.doc-main-editor { + flex: 1; + overflow: auto; + min-height: 0; + min-width: 0; + padding: 24px 0; +} +.doc-main-editor .bn-editor { + background-color: var(--bg); +} + +/* ===== History sidebar (right) ===== */ + +.history-sidebar { + border-left: 1px solid var(--border); + background: var(--bg-elevated); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.history-header { + padding: 14px 16px 8px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} +.history-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-subtle); +} +.history-filter { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} +.history-filter-btn { + padding: 3px 10px; + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + background: var(--bg); + transition: + background 0.12s ease, + color 0.12s ease; + border: none; + cursor: pointer; +} +.history-filter-btn:not(:last-child) { + border-right: 1px solid var(--border); +} +.history-filter-btn:hover { + background: var(--bg-hover); + color: var(--text); +} +.history-filter-btn.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} +.history-content { + flex: 1; + overflow-y: auto; + padding: 0 8px 16px; +} + +/* ===== BlockNote versioning sidebar (snapshot cards) ===== */ + +.history-content .bn-versioning-sidebar { + border: none; + background: transparent; + padding-inline: 8px; +} + +.bn-snapshot { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 8px; + overflow: visible; + padding: 14px 18px; + width: 100%; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; +} +.bn-snapshot:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-md); +} + +.bn-snapshot.selected { + background-color: var(--accent-soft); + border: 2px solid var(--accent); +} + +.bn-snapshot-body { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 4px; + color: var(--text-muted); +} + +.bn-snapshot-name { + background: transparent; + border: none; + color: var(--text); + font-size: 14px; + font-weight: 600; + padding: 0; + width: 100%; + font-family: inherit; +} +.bn-snapshot-name:focus { + outline: none; +} +.bn-snapshot-name::placeholder { + color: var(--text-subtle); +} + +.bn-snapshot-date { + color: var(--text-subtle); + font-size: 12px; +} + +.bn-snapshot-original-date { + color: var(--text-subtle); + font-size: 11px; + font-style: italic; +} + +.bn-snapshot-secondary-label { + color: var(--text-subtle); + font-size: 11px; +} + +.bn-snapshot-button { + background-color: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: white; + cursor: pointer; + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + width: fit-content; + transition: background-color 0.12s ease; +} +.bn-snapshot-button:hover { + background-color: var(--accent-hover); +} + +/* ===== Misc ===== */ + +.page-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + color: var(--text-muted); + font-size: 14px; +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts b/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts new file mode 100644 index 0000000000..5afb4ca869 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/userdata.ts @@ -0,0 +1,56 @@ +import type { User } from "@blocknote/core"; + +export function getById(id: string): User { + return ( + USERS.find((u) => u.id === id) ?? { + id, + username: "Unknown", + avatarUrl: "", + color: "#000000", + colorLight: "#cccccc", + } + ); +} + +// Integer-like ids make it obvious if username resolution ever breaks: the UI +// would show a bare number (e.g. "1") instead of a name. +export const USERS: User[] = [ + { + id: "1", + username: "Alice", + avatarUrl: "", + color: "#e6194b", + colorLight: "#e6194b33", + }, + { + id: "2", + username: "Bob", + avatarUrl: "", + color: "#3cb44b", + colorLight: "#3cb44b33", + }, + { + id: "3", + username: "Charlie", + avatarUrl: "", + color: "#f58231", + colorLight: "#f5823133", + }, + { + id: "4", + username: "Dana", + avatarUrl: "", + color: "#4363d8", + colorLight: "#4363d833", + }, +]; + +/** + * Resolves user ids to user info. Passed to the collaboration options as + * `resolveUsers`, which the versioning UI uses to display version authors (and + * diff tooltips) by name instead of id. Mirrors the `resolveUsers` you'd + * normally back with your own user database. + */ +export async function resolveUsers(userIds: string[]): Promise { + return USERS.filter((u) => userIds.includes(u.id)); +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/utils.ts b/examples/07-collaboration/12-multi-doc-versioning/src/utils.ts new file mode 100644 index 0000000000..08eb873511 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/src/utils.ts @@ -0,0 +1,106 @@ +const ID_CHARS = "abcdefghjkmnpqrstuvwxyz23456789"; + +export const generateRandomId = (length: number): string => { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + let id = ""; + for (let i = 0; i < bytes.length; i++) { + id += ID_CHARS[bytes[i] % ID_CHARS.length]; + } + return id; +}; + +const DOC_ADJECTIVES = [ + "Quiet", + "Bright", + "Gentle", + "Curious", + "Tangled", + "Shimmering", + "Restless", + "Polished", + "Folded", + "Loose", + "Scattered", + "Hidden", + "Patient", + "Stubborn", + "Winding", + "Borrowed", + "Plain", + "Dusty", + "Silver", + "Wild", + "Half", + "Unfinished", + "Morning", + "Midnight", + "Parallel", + "Open", + "Stray", + "Sunlit", + "Crooked", + "Spare", +]; + +const DOC_NOUNS = [ + "Draft", + "Notebook", + "Sketch", + "Memo", + "Chapter", + "Outline", + "Margin", + "Thought", + "Idea", + "Plan", + "Passage", + "Letter", + "Log", + "Journal", + "Scrap", + "Leaf", + "Manuscript", + "Record", + "Fragment", + "Brief", + "Entry", + "Column", + "Folder", + "Canvas", + "Report", + "Section", + "Page", + "Transcript", + "Ledger", + "Dossier", +]; + +export const generateDocTitle = (): string => { + const adj = DOC_ADJECTIVES[Math.floor(Math.random() * DOC_ADJECTIVES.length)]; + const noun = DOC_NOUNS[Math.floor(Math.random() * DOC_NOUNS.length)]; + return adj + " " + noun; +}; + +export const formatRelative = ( + ts: number, + { justNowMs = 60_000 } = {}, +): string => { + if (!ts) { + return ""; + } + const diff = Date.now() - ts; + if (diff < justNowMs) { + return "just now"; + } + if (diff < 3_600_000) { + return Math.floor(diff / 60_000) + "m ago"; + } + if (diff < 86_400_000) { + return Math.floor(diff / 3_600_000) + "h ago"; + } + if (diff < 7 * 86_400_000) { + return Math.floor(diff / 86_400_000) + "d ago"; + } + return new Date(ts).toLocaleDateString(); +}; diff --git a/examples/07-collaboration/12-multi-doc-versioning/tsconfig.json b/examples/07-collaboration/12-multi-doc-versioning/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/12-multi-doc-versioning/vite-env.d.ts b/examples/07-collaboration/12-multi-doc-versioning/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/07-collaboration/13-versioning-yjs14/.bnexample.json b/examples/07-collaboration/13-versioning-yjs14/.bnexample.json new file mode 100644 index 0000000000..3dd5b97f43 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/.bnexample.json @@ -0,0 +1,13 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Advanced", "Development", "Collaboration"], + "dependencies": { + "@y/prosemirror": "^2.0.0-4", + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.16", + "lib0": "1.0.0-rc.13" + } +} diff --git a/examples/07-collaboration/13-versioning-yjs14/README.md b/examples/07-collaboration/13-versioning-yjs14/README.md new file mode 100644 index 0000000000..c27eecc8c2 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/README.md @@ -0,0 +1,10 @@ +# YHub Versioning (@y/y v14) + +This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Snapshots are stored in localStorage using Yjs v2 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. + +**Relevant Docs:** + +- [Editor Setup](/docs/getting-started/editor-setup) +- [Real-time collaboration](/docs/features/collaboration) diff --git a/examples/07-collaboration/13-versioning-yjs14/index.html b/examples/07-collaboration/13-versioning-yjs14/index.html new file mode 100644 index 0000000000..a5658ceaeb --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/index.html @@ -0,0 +1,14 @@ + + + + + YHub Versioning (@y/y v14) + + + +
+ + + diff --git a/examples/07-collaboration/13-versioning-yjs14/main.tsx b/examples/07-collaboration/13-versioning-yjs14/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/13-versioning-yjs14/package.json b/examples/07-collaboration/13-versioning-yjs14/package.json new file mode 100644 index 0000000000..c0bb067d99 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/package.json @@ -0,0 +1,35 @@ +{ + "name": "@blocknote/example-collaboration-versioning-yjs14", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@y/prosemirror": "^2.0.0-4", + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.16", + "lib0": "1.0.0-rc.13" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/App.tsx b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx new file mode 100644 index 0000000000..03897b0528 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx @@ -0,0 +1,169 @@ +import "@blocknote/core/fonts/inter.css"; +import { + createYHubVersioningEndpoints, + withCollaboration, +} from "@blocknote/core/y"; +import { VersioningExtension } from "@blocknote/core/extensions"; +import { + BlockNoteViewEditor, + useCreateBlockNote, + useExtension, + useExtensionState, + VersioningSidebar, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import * as Y from "@y/y"; +import { WebsocketProvider } from "@y/websocket"; + +import { seedSampleVersions } from "./sampleDocument"; +import { resolveUsers } from "./userdata"; +import "./style.css"; + +// YHub serves both real-time sync (over WebSocket) and version history (over +// HTTP) for the same document, so the backend URL, org, and docId are shared. +const yhubHost = "yhub.teleportal.tools"; +const org = "blocknote"; +const docId = `blocknote-version-yjs14-${Math.floor(Date.now())}`; + +// YHub-backed versioning endpoints. YHub stores continuous edit history and +// exposes its activity timeline as versions through BlockNote's versioning UI. +// Constructing this opens no connection, so it's safe to do before seeding. +const versioningEndpoints = createYHubVersioningEndpoints({ + baseUrl: `https://${yhubHost}`, + org, + docId, +}); + +const doc = new Y.Doc(); +const provider = new WebsocketProvider( + `wss://${yhubHost}/ws`, + `${org}/${docId}`, + doc, + { + params: { + userid: "test", + }, + }, +); + +const preparePromise: Promise = (async () => { + // Wait for the server's existing content (if any) to load. + if (!provider.synced) { + await new Promise((resolve) => provider.once("sync", resolve)); + } + + // Seed only when the synced document is genuinely empty. + if (!(doc.get("bn").length > 0)) { + provider.disconnect(); + await seedSampleVersions({ + baseUrl: `https://${yhubHost}`, + org, + docId, + fragment: "bn", + }); + provider.connect(); + } +})(); + +/** + * Gate: prepare the document (seed + connect + first sync) BEFORE creating the + * editor, so the editor adopts the synced content instead of writing a competing + * initial blockGroup. + */ +export default function App() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + void preparePromise + .then(() => { + if (!cancelled) { + setReady(true); + } + }) + .catch(() => { + /* error already logged in prepareDocument */ + }); + return () => { + cancelled = true; + }; + }, []); + + if (!ready) { + return
Preparing document…
; + } + + return ; +} + +function VersionedEditor() { + // The provider is already connected and synced (see `prepareDocument`), and + // the local `doc` holds the server's content, so the editor adopts it. + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + provider: provider ?? undefined, + fragment: doc.get("bn"), + user: { color: "#ff0000", name: "User" }, + // Pass versioningEndpoints to the v14 CollaborationExtension which + // automatically wires up the VersioningExtension with the Yjs adapter. + versioningEndpoints, + // Resolves version-author ids (the seed's `attribution.by`) to usernames + // in the history sidebar and diff tooltips. + resolveUsers, + }, + }), + ); + + const { previewedSnapshotId } = useExtensionState(VersioningExtension, { + editor, + }); + + const [showSidebar, setShowSidebar] = useState(true); + + const versioning = useExtension(VersioningExtension, { editor }); + useEffect(() => { + versioning.list(); + const interval = setInterval(() => { + versioning.list(); + }, 10000); + return () => { + clearInterval(interval); + }; + }, [versioning]); + + return ( +
+ +
+
+ + {!showSidebar && ( + + )} +
+ {showSidebar && ( +
+ setShowSidebar(false)} + /> +
+ )} +
+
+
+ ); +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/reconcile.ts b/examples/07-collaboration/13-versioning-yjs14/src/reconcile.ts new file mode 100644 index 0000000000..c0fd3dcefc --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/reconcile.ts @@ -0,0 +1,423 @@ +import type { BlockNoteEditor, PartialBlock, Block } from "@blocknote/core"; + +/** + * A version of the document is described as a tree of {@link PartialBlock}s, + * each carrying a *stable* `id`. These ids are what lets us tell apart "this is + * the same block, its text changed" from "this block was removed and a new one + * added" — exactly the distinction a naive positional diff gets wrong. + */ +export type VersionBlock = PartialBlock & { + id: string; + children?: VersionBlock[]; +}; + +// --------------------------------------------------------------------------- +// Signature helpers — a "rough diff" key per block. +// +// We hash everything that defines a block *except* its position and its +// children (`type`, `props`, `content`). Two blocks with the same id are +// considered "changed" iff their signatures differ; this is what we use to +// decide whether to emit an `updateBlock` op. +// +// The subtlety: a *live* block (read from `editor.document`) always carries +// its props **fully resolved with schema defaults** (e.g. a paragraph has +// `{ textColor: "default", backgroundColor: "default", textAlignment: "left" }`) +// and every inline text node carries an explicit `styles` object (`{}` when +// unstyled). The *target* blocks (static version JSON) omit default props and +// omit `styles` on unstyled text. A naive hash of the two would therefore +// differ on essentially every block, emitting a spurious `updateBlock` for +// blocks that did not actually change. To compare apples to apples we +// normalize BOTH sides to the same fully-resolved canonical form before +// hashing: props are filled from the editor's block schema, and inline +// content is canonicalized (every text node gets a `styles` object, links get +// normalized content). +// --------------------------------------------------------------------------- + +/** Fill a block's props with schema defaults so both sides hash identically. */ +function resolveProps( + editor: BlockNoteEditor, + type: string | undefined, + props: Record | undefined, +): Record { + const spec = type ? (editor.schema.blockSpecs as any)[type] : undefined; + const propSchema = spec?.config?.propSchema as + | Record + | undefined; + + // Unknown type or string-keyed prop schema: fall back to the given props. + if (!propSchema || typeof propSchema !== "object") { + return { ...(props ?? {}) }; + } + + const resolved: Record = {}; + for (const key of Object.keys(propSchema)) { + const given = props?.[key]; + resolved[key] = given !== undefined ? given : propSchema[key]?.default; + } + // Preserve any extra props not described by the schema (defensive). + for (const key of Object.keys(props ?? {})) { + if (!(key in resolved)) { + resolved[key] = props![key]; + } + } + return resolved; +} + +/** Canonicalize a single inline content node (text / link / custom). */ +function canonInline(node: any): any { + if (node == null || typeof node !== "object") { + return node; + } + if (node.type === "text") { + return { + type: "text", + text: node.text ?? "", + styles: node.styles ?? {}, + }; + } + if (node.type === "link") { + return { + type: "link", + href: node.href, + content: canonContent(node.content), + }; + } + return node; +} + +/** + * Canonicalize a block's `content` (array, plain string, or undefined). + * + * Crucially, an *absent* `content` (target JSON omits it for empty blocks) and + * an *empty* inline array (a live empty paragraph carries `content: []`) are + * the same thing — "no inline content" — and must canonicalize identically, or + * every empty paragraph would diff as changed and emit a spurious update. + */ +function canonContent(content: any): any { + if (content === undefined || content === null) { + return undefined; + } + if (typeof content === "string") { + // A plain-string shorthand is equivalent to a single unstyled text node; + // the empty string means "no content". + return content === "" + ? undefined + : [{ type: "text", text: content, styles: {} }]; + } + if (Array.isArray(content)) { + // Empty inline array == no content. + return content.length === 0 ? undefined : content.map(canonInline); + } + // Table content and other structured content: hash as-is. + return content; +} + +function signature( + editor: BlockNoteEditor, + block: { + type?: string; + props?: Record; + content?: any; + }, +): string { + return JSON.stringify({ + type: block.type, + props: resolveProps(editor, block.type, block.props), + content: canonContent(block.content), + }); +} + +function walk( + blocks: { id: string; children?: any[] }[], + visit: (block: any, parentId: string | undefined) => void, + parentId?: string, +): void { + for (const block of blocks) { + visit(block, parentId); + if (block.children?.length) { + walk(block.children, visit, block.id); + } + } +} + +function collectIds(blocks: { id: string; children?: any[] }[]): Set { + const ids = new Set(); + walk(blocks, (b) => ids.add(b.id)); + return ids; +} + +/** + * Strip a {@link VersionBlock} down to the partial block we hand to + * `insertBlocks`. Crucially we keep the explicit `id` (so the inserted block + * keeps its identity across versions) and the nested `children`. + * + * `liveIds`, when provided, marks blocks that *already exist* in the document. + * Such descendants are **omitted** from the inserted subtree: they were not + * created here, they were *reparented* into this new block, so they must be + * carried over by an explicit move (done by the recursion in `reconcileList`) + * rather than duplicated as a fresh copy. Without this, a new parent that + * adopts existing children would clone them, leaving two blocks with one id. + */ +function toPartial( + block: VersionBlock, + liveIds?: Set, +): PartialBlock { + const partial: any = { id: block.id, type: block.type }; + if (block.props) { + partial.props = block.props; + } + if (block.content !== undefined) { + partial.content = block.content; + } + const children = block.children as VersionBlock[] | undefined; + if (children?.length) { + const freshChildren = liveIds + ? children.filter((c) => !liveIds.has(c.id)) + : children; + if (freshChildren.length) { + partial.children = freshChildren.map((c) => toPartial(c, liveIds)); + } + } + return partial; +} + +/** + * Reconcile the editor's current document so it exactly matches `target`, + * emitting the *minimal* set of BlockNote ops to get there: + * + * - `removeBlocks` for ids that disappeared, + * - `updateBlock` for ids whose type/props/content changed, + * - `insertBlocks` for brand-new ids (whole subtrees at once), + * - a move (remove + re-insert, preserving id) for blocks whose parent or + * sibling order changed. + * + * Because ids are stable, an edit that *looks* like "delete + re-add" in a + * positional diff is correctly recognised here as an in-place update or a move, + * which is the semantic operation a human actually performed. + */ +export function applyVersion( + editor: BlockNoteEditor, + target: VersionBlock[], +): void { + editor.transact(() => { + const targetIds = collectIds(target); + + // --- Fast path: building from scratch. If none of the live blocks survive + // into the target (e.g. the very first version applied to a fresh editor, + // whose only block is the default empty paragraph), replacing the whole + // document in one op avoids transiently emptying it — which would leave a + // transient id-less placeholder block that the incremental insert path + // cannot anchor against. + const liveIds = collectIds(editor.document); + const anySurvive = [...liveIds].some((id) => targetIds.has(id)); + if (!anySurvive) { + editor.replaceBlocks( + editor.document, + target.map((b) => toPartial(b)), + ); + return; + } + + // --- 1. Removals. Only remove "roots" of removed subtrees: if a block is + // gone, all of its descendants go with it, so removing the topmost gone + // ancestor is enough (and removing a child after its parent would throw). + const toRemove: string[] = []; + walk(editor.document, (block, parentId) => { + if (targetIds.has(block.id)) { + return; + } + // Skip if an ancestor is already being removed. + if (parentId && toRemove.includes(parentId)) { + return; + } + toRemove.push(block.id); + }); + if (toRemove.length > 0) { + editor.removeBlocks(toRemove); + } + + // --- 2 & 3. Walk the target tree in document order and reconcile each + // block: insert if new, update if changed, move if mis-placed. Recursing in + // order means earlier siblings are already in place to anchor against. + reconcileList(editor, target, undefined); + }); +} + +/** Look up a block in the live document by id (depth-first). */ +function getLiveBlock( + editor: BlockNoteEditor, + id: string, +): Block | undefined { + let found: Block | undefined; + walk(editor.document, (b) => { + if (!found && b.id === id) { + found = b as Block; + } + }); + return found; +} + +function reconcileList( + editor: BlockNoteEditor, + targetSiblings: VersionBlock[], + parent: VersionBlock | undefined, +): void { + let prevId: string | undefined; + + for (const targetBlock of targetSiblings) { + const live = getLiveBlock(editor, targetBlock.id); + + if (!live) { + // --- Brand-new block. Insert it together with its *new* descendants, + // but omit any descendants that already exist elsewhere in the document + // (they were reparented in): those are placed by the recursion below, + // which moves them rather than cloning them. + const liveIds = collectIds(editor.document); + insertAt(editor, toPartial(targetBlock, liveIds), parent, prevId); + // Recurse so reparented (already-live) children get moved into place and + // any further new descendants are positioned correctly. + reconcileList(editor, targetBlock.children ?? [], targetBlock); + } else { + // --- Existing block. Does its own signature differ? (children handled + // by recursion, so compare without them.) + if (signature(editor, live) !== signature(editor, targetBlock)) { + const update: PartialBlock = { type: targetBlock.type }; + if (targetBlock.props) { + update.props = targetBlock.props as any; + } + if (targetBlock.content !== undefined) { + update.content = targetBlock.content as any; + } else if ( + Array.isArray((live as any).content) && + (live as any).content.length > 0 + ) { + // Target has no inline content but the live block still does: the + // block was *emptied* (its text cleared). An update that simply omits + // `content` would leave the stale text in place, so clear it + // explicitly. (Content-less block types like `divider`/`image` never + // hit this branch because their live `content` isn't an array.) + update.content = [] as any; + } + editor.updateBlock(targetBlock.id, update); + } + + // --- Is it in the right place (correct parent + after prevId)? + if (!isPlacedAfter(editor, targetBlock.id, parent, prevId)) { + moveAfter(editor, targetBlock.id, parent, prevId); + } + + // --- Recurse into children. + reconcileList(editor, targetBlock.children ?? [], targetBlock); + } + + prevId = targetBlock.id; + } +} + +/** True if `id`'s previous sibling is `prevId` and its parent is `parent`. */ +function isPlacedAfter( + editor: BlockNoteEditor, + id: string, + parent: VersionBlock | undefined, + prevId: string | undefined, +): boolean { + const liveParent = parentOf(editor, id); + if ((liveParent?.id ?? undefined) !== (parent?.id ?? undefined)) { + return false; + } + const siblings = liveParent + ? (getLiveBlock(editor, liveParent.id)?.children ?? []) + : editor.document; + const idx = siblings.findIndex((b) => b.id === id); + const actualPrev = idx > 0 ? siblings[idx - 1].id : undefined; + return actualPrev === prevId; +} + +/** Find the live parent block of `id` (undefined => top level). */ +function parentOf( + editor: BlockNoteEditor, + id: string, +): Block | undefined { + let parent: Block | undefined; + walk(editor.document, (block) => { + if (block.children?.some((c: any) => c.id === id)) { + parent = block as Block; + } + }); + return parent; +} + +/** + * Insert `partial` so it lands after `prevId` inside `parent` (or as the first + * child of `parent`, or at the very top of the document). + */ +function insertAt( + editor: BlockNoteEditor, + partial: PartialBlock, + parent: VersionBlock | undefined, + prevId: string | undefined, +): void { + if (prevId) { + editor.insertBlocks([partial], prevId, "after"); + return; + } + // First in its sibling list. + if (parent) { + const liveParent = getLiveBlock(editor, parent.id); + const firstChild = liveParent?.children?.[0]; + if (firstChild) { + editor.insertBlocks([partial], firstChild.id, "before"); + } else { + // Parent has no children yet: attach as its only child via updateBlock. + editor.updateBlock(parent.id, { children: [partial] } as any); + } + return; + } + // Top of the document. + const firstTop = editor.document[0]; + if (firstTop?.id) { + editor.insertBlocks([partial], firstTop.id, "before"); + } else { + // Empty document (or a transient id-less placeholder block): replace it + // wholesale rather than trying to anchor against a block with no id. + editor.replaceBlocks(editor.document, [partial]); + } +} + +/** + * Move an existing block (by id) so it sits after `prevId` within `parent`. + * Implemented as remove + re-insert, carrying the block's *current* content and + * children so nothing is lost — only its position changes. + */ +function moveAfter( + editor: BlockNoteEditor, + id: string, + parent: VersionBlock | undefined, + prevId: string | undefined, +): void { + const live = getLiveBlock(editor, id); + if (!live) { + return; + } + const partial = blockToPartial(live); + editor.removeBlocks([id]); + insertAt(editor, partial, parent, prevId); +} + +/** Turn a live {@link Block} back into a {@link PartialBlock}, keeping its id. */ +function blockToPartial( + block: Block, +): PartialBlock { + const partial: any = { + id: block.id, + type: block.type, + props: block.props, + }; + if (block.content !== undefined) { + partial.content = block.content; + } + if (block.children?.length) { + partial.children = block.children.map(blockToPartial); + } + return partial; +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts new file mode 100644 index 0000000000..da8138311b --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts @@ -0,0 +1,98 @@ +import { BlockNoteEditor } from "@blocknote/core"; + +import type { VersionBlock } from "./reconcile"; +import { buildContributions } from "./splitContributions"; +import { buildSnapshots } from "./snapshotBuilder"; +import type { SnapshotStep } from "./snapshotBuilder"; +import { seedYHubDocument } from "./seed"; +import { VERSIONS } from "./versions"; + +/** + * The history of a real BlockNote project-status document, replayed as five + * named versions — each one attributed to *several* users. Seeding it builds + * real Yjs history and PATCHes it to YHub, so the editor opens with rich + * content AND a populated version history where each version shows multiple + * contributors. + * + * Each version of the document is stored fully (a tree of blocks with stable + * ids) in `./versions`. {@link buildContributions} splits the work of reaching + * each version across that version's authors — round-robin–assigning the + * top-level sections — and hands each author's *intermediate* target to + * {@link applyVersion}, which performs a rough id+hash diff against the editor's + * current state and emits only the minimal ops that get there: + * + * - `insertBlocks` for genuinely-new blocks (whole subtrees at once), + * - `updateBlock` for blocks whose type / props / content changed, + * - `removeBlocks` for blocks that disappeared, + * - a move (remove + re-insert, keeping the id) for blocks that were + * reparented or reordered. + * + * Each author's changes become a separately-attributed Yjs transaction, and + * `seedYHubDocument` lands them as separate authored content before committing + * a single version marker — so the one version is attributed to every author. + */ + +/** Each version's target tree plus the 2–3 users who collaborate on it. */ +const VERSION_PLAN: Array<{ + name: string; + target: VersionBlock[]; + authors: string[]; +}> = [ + // `authors` are user ids (see `userdata.ts`): 1 Alice, 2 Bob, 3 Carol, + // 4 Dave, 5 Erin. They flow through to `attribution.by` and are resolved back + // to usernames by the collaboration user store in the versioning UI. + { name: "Initial budget skeleton", target: VERSIONS.v1, authors: ["1", "2"] }, + { + name: "Flesh out the full project document", + target: VERSIONS.v2, + authors: ["2", "3", "4"], + }, + { + name: "Add estimates and next steps", + target: VERSIONS.v3, + authors: ["1", "3"], + }, + { + name: "Expand schema options and POC links", + target: VERSIONS.v4, + authors: ["2", "4", "5"], + }, + { + name: "Update budget numbers and trim", + target: VERSIONS.v5, + authors: ["3", "5"], + }, +]; + +export const SAMPLE_STEPS: SnapshotStep[] = VERSION_PLAN.map((plan, index) => { + const base = index === 0 ? [] : VERSION_PLAN[index - 1].target; + return { + name: plan.name, + contributions: buildContributions(base, plan.target, plan.authors).map( + (contribution) => ({ + attribution: contribution.attribution, + changes: contribution.changes, + }), + ), + }; +}); + +/** + * 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. + * + * The `fragment` must match the key the live editor reads (`doc.get(fragment)`). + */ +export async function seedSampleVersions(opts: { + baseUrl: string; + org: string; + docId: string; + fragment: string; +}): Promise { + const editor = BlockNoteEditor.create(); + const build = await buildSnapshots(editor, SAMPLE_STEPS, { + fragment: opts.fragment, + }); + await 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 new file mode 100644 index 0000000000..5846cc047a --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts @@ -0,0 +1,168 @@ +import * as Y from "@y/y"; +import { encodeAny } from "lib0/buffer"; +import { uint32 } from "lib0/random"; + +import type { BuildSnapshotsResult } from "./snapshotBuilder"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SeedYHubDocumentOptions { + /** Base URL of the YHub API (e.g. `"https://yhub.example.com"`), no trailing slash. */ + baseUrl: string; + /** YHub organisation identifier. */ + org: string; + /** Document identifier within the organisation. */ + docId: string; + /** Optional headers to include in every request (e.g. auth tokens). */ + headers?: Record; +} + +/** A version marker created on the server while seeding. */ +export interface SeededVersion { + id: string; + name: string; +} + +/** The parts of a {@link BuildSnapshotsResult} that {@link seedYHubDocument} needs. */ +export type SeedableBuild = Pick< + BuildSnapshotsResult, + "baseUpdate" | "snapshots" +>; + +// --------------------------------------------------------------------------- +// seedYHubDocument +// --------------------------------------------------------------------------- + +/** A single patch in a YHub bulk-`patches` PATCH body. */ +type YHubPatch = { + /** V1 Yjs update with this patch's novel content. */ + update: Uint8Array; + /** Author to attribute the content to (YHub `userid`). */ + by?: string; + /** Timestamp override (unix ms), so backfilled history stays ordered. */ + at?: number; + /** Custom attributions riding this patch's content (e.g. version markers). */ + 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 buildSnapshots} result, without a live editor / sync connection. + * + * Each step's contributions are PATCHed to `/ydoc/{org}/{docId}` as a single + * ordered `patches` bulk request: one content patch per contributing user + * (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 BuildSnapshotsResult.baseUpdate}) is PATCHed first, without a marker, + * so the step updates have their baseline to merge onto. + * + * Every patch carries an explicit, monotonically increasing `at` timestamp so + * the backfilled history stays deterministically ordered (content before its + * marker, each version after the previous one). + * + * YHub speaks the V1 update format, so the V2 updates `buildSnapshots` produces + * are converted; the synthetic marker update is already V1. + * + * @returns the version markers created, in order. + * + * @example + * ```ts + * const editor = BlockNoteEditor.create(); + * // NOTE: target the same fragment key the live editor reads (`doc.get()` => "") + * const build = await buildSnapshots(editor, steps, { fragment: "" }); + * await seedYHubDocument( + * { baseUrl: "https://yhub.example.com", org: workspaceId, docId }, + * build, + * ); + * ``` + */ +export async function seedYHubDocument( + options: SeedYHubDocumentOptions, + build: SeedableBuild, +): Promise { + const { baseUrl, org, docId, headers = {} } = options; + const url = `${baseUrl}/ydoc/${org}/${docId}`; + + const send = async (body: Record) => { + const res = await fetch(url, { + method: "PATCH", + headers, + body: encodeAny(body) as BufferSource, + }); + if (!res.ok) { + throw new Error( + `YHub seed request failed: ${res.status} ${res.statusText} (${url})`, + ); + } + }; + + // Monotonic clock for the whole seed, so every patch is ordered and each + // version's content lands strictly before its marker. + let at = Date.now(); + + // 1. Starting document state — content only, no version marker. + await send({ + update: Y.convertUpdateFormatV2ToV1(build.baseUpdate), + at: at++, + customAttributions: [], + }); + + // 2. Each step: its per-user content patches, then a single `type:version` + // marker patch so it appears as one snapshot attributed to every author. + const versions: SeededVersion[] = []; + for (const snapshot of build.snapshots) { + const id = String(uint32()); + + const patches: YHubPatch[] = []; + let lastAuthor: string | undefined; + for (const contribution of snapshot.contributions) { + const by = contribution.attribution?.by; + const author = typeof by === "string" ? by : undefined; + if (author) { + lastAuthor = author; + } + patches.push({ + update: Y.convertUpdateFormatV2ToV1(contribution.update), + by: author, + at: at++, + customAttributions: [], + }); + } + // The marker patch carries the version itself. YHub attributes an entry to a + // single user, so credit the version to its most recent contributor (the + // per-content attribution still records who authored each part). + patches.push({ + update: makeVersionMarkerUpdate(), + by: lastAuthor, + at: at++, + customAttributions: [ + { k: "type", v: "version" }, + { k: "id", v: id }, + { k: "name", v: snapshot.name }, + ], + }); + + await send({ patches }); + versions.push({ id, name: snapshot.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 new file mode 100644 index 0000000000..432aeeeb57 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts @@ -0,0 +1,263 @@ +import { type Block, BlockNoteEditor, docToBlocks } from "@blocknote/core"; +import { docDiffToDelta } from "@blocknote/core/y"; +import { deltaToPNode, docToDelta } from "@y/prosemirror"; +import * as Y from "@y/y"; + +/** + * Build up Yjs snapshots of a document at named points in time. + * + * The idea: describe a document's history as a list of named steps. Each step + * receives the *same* editor instance that the previous step mutated, makes + * some changes, and we record a Yjs snapshot of the document at that point. The + * snapshots can later be reconstructed and diffed against each other. + * + * This deliberately does NOT use the y-prosemirror sync plugin. Instead, for + * each step we: + * 1. run the step's `changes` against the editor, + * 2. diff the editor's new ProseMirror doc against the previous one + * (`docDiffToDelta`), + * 3. apply that delta to a plain Y.Type inside its own transaction (tagged + * with the step's `attribution` as the transaction origin), building up + * real Yjs history, + * 4. emit a {@link SnapshotEvent} (before / after blocks + the diff as both a + * Yjs update and a ProseMirror delta) via `onSnapshot`. + * + * Because we want snapshots to stay valid, the backing Y.Doc has gc disabled. + * + * @example Pre-populate a doc with multi-author history, then ship it + * ```ts + * const editor = createSnapshotEditor(); + * const result = await buildSnapshots(editor, [ + * { + * name: "Intro", + * contributions: [ + * { attribution: { by: "alice" }, changes: (e) => { } }, + * { attribution: { by: "bob" }, changes: (e) => { } }, + * ], + * }, + * ], { + * onSnapshot: async ({ name, contributions }) => { + * // e.g. PATCH each contribution to YHub attributed to its author (see + * // yhub.ts), then commit a single version marker named `name`. + * for (const { attribution, update } of contributions) { + * await storeToServer(update, { ...attribution }); + * } + * }, + * }); + * + * // Or seed the whole document at once (matches the example's localStorage + * // `bn-doc-state-` key, which is a base64 V2 update): + * const fullUpdate = Y.encodeStateAsUpdateV2(result.ydoc); + * ``` + */ + +/** Arbitrary attribution attached to a contribution (e.g. `{ by: "alice" }`). */ +export type SnapshotAttribution = Record; + +/** + * A single attributed contribution to a version. Each contribution is applied + * in its own Yjs transaction (origin = its `attribution`), so a single version + * can carry content authored by several different users. + */ +export type SnapshotContribution = { + /** + * Attribution for this contribution's changes (e.g. `{ by: "alice" }`). Set + * as the Yjs transaction origin, so callers can map it to e.g. YHub + * `customAttributions` / `?userid=` to differentiate who changed what. + */ + attribution?: SnapshotAttribution; + /** + * Mutate the editor. Receives the same editor instance the previous + * contribution (or step) left off with, so changes accumulate. + */ + changes: (editor: BlockNoteEditor) => void; +}; + +/** A single named version in the document's history, built from one or more attributed contributions. */ +export type SnapshotStep = { + name: string; + /** The attributed contributions that together produce this version. */ + contributions: SnapshotContribution[]; +}; + +/** One attributed contribution's change, as both a Yjs update and a ProseMirror delta. */ +export type SnapshotContributionDiff = { + /** Attribution for this contribution, if any. */ + attribution?: SnapshotAttribution; + /** + * V2 Yjs update containing only this contribution's transaction. Apply + * sequentially (`Y.applyUpdateV2`) / PATCH to a server to rebuild the history. + */ + update: Uint8Array; + /** The ProseMirror delta transforming the previous doc into the new one. */ + delta: ReturnType; +}; + +/** Emitted once per step, after all of its contributions have been applied to the Y.Doc. */ +export type SnapshotEvent = { + /** Zero-based index of the step. */ + index: number; + /** The step's name. */ + name: string; + /** + * The attributed contributions that produced this version, in order. Always + * at least one; no-op contributions (that changed nothing) are omitted. + */ + contributions: SnapshotContributionDiff[]; + /** The document (block JSON) before this step's changes. */ + before: Block[]; + /** The document (block JSON) after this step's changes. */ + after: Block[]; + /** A Yjs snapshot of the doc at this point in time. */ + snapshot: Y.Snapshot; +}; + +export type BuildSnapshotsOptions = { + /** Root key / fragment name on the Y.Doc. @default "prosemirror" */ + fragment?: string; + /** + * Called once per step, after its changes have been applied to the Y.Doc. + * May be async — steps are processed sequentially and each callback is + * awaited before the next step runs, so server writes stay ordered. + */ + onSnapshot?: (event: SnapshotEvent) => void | Promise; +}; + +export type BuildSnapshotsResult = { + /** The editor instance threaded through every step. */ + editor: BlockNoteEditor; + /** The backing (gc-disabled) Y.Doc holding the full history. */ + ydoc: Y.Doc; + /** The Y.Type the ProseMirror content was synced into. */ + yType: Y.Type; + /** The fragment / root key used on the Y.Doc. */ + fragment: string; + /** + * V2 update for the starting document state (the editor's initial doc), + * before any step ran. When replaying the per-step `diff.update`s onto a + * blank doc, apply this FIRST — the step updates are relative to it. + */ + baseUpdate: Uint8Array; + /** One event per step, in order. */ + snapshots: SnapshotEvent[]; +}; + +/** + * Run a list of named steps against a single editor, recording a Yjs snapshot + * of the document after each step and emitting a {@link SnapshotEvent}. + */ +export async function buildSnapshots( + editor: BlockNoteEditor, + steps: SnapshotStep[], + options: BuildSnapshotsOptions = {}, +): Promise { + const fragment = options.fragment ?? "prosemirror"; + + // gc must be off so snapshots remain reconstructable later. + const ydoc = new Y.Doc({ gc: false }); + const yType = ydoc.get(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 `previousDoc`. Capture + // the empty state vector first so we can expose the seed as `baseUpdate`. + const emptyStateVector = Y.encodeStateVector(ydoc); + let previousDoc = editor.prosemirrorState.doc; + ydoc.transact(() => { + yType.applyDelta(docToDelta(previousDoc) as any); + }); + const baseUpdate = Y.encodeStateAsUpdateV2(ydoc, emptyStateVector); + + const snapshots: SnapshotEvent[] = []; + + for (let index = 0; index < steps.length; index++) { + const step = steps[index]; + const beforeDoc = previousDoc; + + // Each contribution mutates the editor in its own transaction (origin = its + // attribution), so we capture one update per author. Together they produce + // this version — but the version marker is committed separately by the + // consumer (see seed.ts), letting multiple users be attributed within it. + const contributions: SnapshotContributionDiff[] = []; + for (const contribution of step.contributions) { + const docBefore = previousDoc; + editor.transact(() => { + contribution.changes(editor); + }); + const newDoc = editor.prosemirrorState.doc; + previousDoc = newDoc; + + // Skip no-op contributions: the author changed nothing, so there is no + // update to attribute or PATCH. + if (newDoc.eq(docBefore)) { + continue; + } + + const delta = docDiffToDelta(docBefore, newDoc); + const beforeStateVector = Y.encodeStateVector(ydoc); + ydoc.transact(() => { + yType.applyDelta(delta as any); + }, contribution.attribution); + const update = Y.encodeStateAsUpdateV2(ydoc, beforeStateVector); + + contributions.push({ + attribution: contribution.attribution, + update, + delta, + }); + } + + const event: SnapshotEvent = { + index, + name: step.name, + contributions, + before: docToBlocks(beforeDoc), + after: docToBlocks(previousDoc), + snapshot: Y.snapshot(ydoc), + }; + snapshots.push(event); + await options.onSnapshot?.(event); + } + + return { editor, ydoc, yType, fragment, baseUpdate, snapshots }; +} + +/** + * Reconstruct the ProseMirror root node a snapshot represented. + */ +export function snapshotToProsemirrorNode( + result: Pick, + snapshot: Y.Snapshot, +) { + const restored = Y.createDocFromSnapshot(result.ydoc, snapshot); + const restoredType = restored.get(result.fragment); + return deltaToPNode(restoredType.toDeltaDeep(), result.editor.pmSchema, null); +} + +/** + * Reconstruct the BlockNote document (block JSON) a snapshot represented. + */ +export function snapshotToBlocks( + result: Pick, + snapshot: Y.Snapshot, +): Block[] { + return docToBlocks(snapshotToProsemirrorNode(result, snapshot)); +} + +/** + * Diff two snapshots, returning the before/after blocks and the ProseMirror + * delta that transforms one into the other. + */ +export function diffSnapshots( + result: Pick, + before: Y.Snapshot, + after: Y.Snapshot, +) { + const beforeNode = snapshotToProsemirrorNode(result, before); + const afterNode = snapshotToProsemirrorNode(result, after); + + return { + before: docToBlocks(beforeNode), + after: docToBlocks(afterNode), + delta: docDiffToDelta(beforeNode, afterNode), + }; +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/splitContributions.ts b/examples/07-collaboration/13-versioning-yjs14/src/splitContributions.ts new file mode 100644 index 0000000000..57d4b4c65c --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/splitContributions.ts @@ -0,0 +1,70 @@ +import type { BlockNoteEditor } from "@blocknote/core"; + +import { applyVersion } from "./reconcile"; +import type { SnapshotContribution } from "./snapshotBuilder"; +import type { VersionBlock } from "./reconcile"; + +/** + * Split the work of reaching `target` (from `base`) across several `authors`, + * so a single version ends up attributed to *multiple* users. + * + * The document's top-level sections are round-robin–assigned to `authors` by + * position. Each author then gets one {@link SnapshotContribution} that reveals + * only their assigned sections (in `target` form) while leaving everyone else's + * sections in their `base` form — so each author's transaction touches only + * their own sections. The contributions are cumulative: applied in order, the + * last one yields the full `target`. + * + * `buildSnapshots` runs each contribution in its own attributed Yjs + * transaction, and `seedYHubDocument` PATCHes each as separate authored content + * before committing one version marker — which is what lands multiple users + * inside the one version. Contributions that turn out to be no-ops (an author + * whose sections didn't actually change) are dropped by `buildSnapshots`. + */ +export function buildContributions( + base: VersionBlock[], + target: VersionBlock[], + authors: string[], +): SnapshotContribution[] { + return authors.map((by, authorIndex) => ({ + attribution: { by }, + changes: (editor: BlockNoteEditor) => + applyVersion( + editor, + intermediateTarget(base, target, authors, authorIndex), + ), + })); +} + +/** + * The document as it should look once authors `0..revealUpTo` (inclusive) have + * contributed: their assigned `target` sections are revealed, every other + * pre-existing section stays in its `base` form, and sections owned by a + * not-yet-revealed author that don't exist in `base` are omitted. + */ +function intermediateTarget( + base: VersionBlock[], + target: VersionBlock[], + authors: string[], + revealUpTo: number, +): VersionBlock[] { + const baseById = new Map(base.map((section) => [section.id, section])); + const out: VersionBlock[] = []; + + target.forEach((section, position) => { + const authorIndex = position % authors.length; + if (authorIndex <= revealUpTo) { + // This author has contributed: reveal the section in its target form. + out.push(section); + } else { + // Not yet revealed: keep the pre-existing section untouched, or omit it + // entirely if it's brand new (so reconcile doesn't add it early). + const previous = baseById.get(section.id); + if (previous) { + out.push(previous); + } + } + }); + + return out; +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/style.css b/examples/07-collaboration/13-versioning-yjs14/src/style.css new file mode 100644 index 0000000000..3a4f0151ff --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/style.css @@ -0,0 +1,99 @@ +/* App layout only. The versioning sidebar's own styling (header, snapshot + rows, selected/comparing states, the "..." menu) ships with the UI library + (@blocknote/mantine etc.), so it isn't repeated here. */ + +.wrapper { + height: calc(100vh - 20px); +} + +.wrapper.loading { + display: flex; + align-items: center; + justify-content: center; + color: #888; + 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 { + flex: 1; + height: calc(100vh - 20px); + min-width: 0; + overflow: auto; +} + +.editor-panel .bn-container { + height: calc(100vh - 20px); + margin: 0; + max-width: none; + padding: 0; +} + +.editor-panel .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); + overflow: auto; + width: 350px; +} + +.dark .sidebar-section { + border-left-color: #2c2c2c; + box-shadow: -6px 0 16px rgba(0, 0, 0, 0.3); +} + +.sidebar-section .settings { + padding: 8px; +} + +.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 { + display: flex; + gap: 10px; +} + +.settings-select .bn-toolbar { + align-items: center; +} + +.settings-select h2 { + color: var(--bn-colors-menu-text); + margin: 0; + font-size: 12px; + line-height: 12px; + padding-left: 14px; +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts new file mode 100644 index 0000000000..e692a99add --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts @@ -0,0 +1,32 @@ +import type { User, UserStore } from "@blocknote/core"; + +// Integer-like ids make it obvious if username resolution ever breaks: the +// 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`. +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" }, +]; + +/** + * Resolves user ids to user info. Passed to the collaboration options as + * `resolveUsers`, which the versioning UI uses to display version authors (and + * diff tooltips) by name instead of id. Mirrors the `resolveUsers` you'd + * normally back with your own user database. + */ +export async function resolveUsers( + userIds: string[], + store: UserStore, +): Promise { + setTimeout( + () => { + store.setUser(USERS.filter((u) => userIds.includes(u.id))); + }, + Math.random() * 200 + 300, + ); + return [USERS[0]]; +} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/versions.ts b/examples/07-collaboration/13-versioning-yjs14/src/versions.ts new file mode 100644 index 0000000000..5920669812 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/src/versions.ts @@ -0,0 +1,6701 @@ +import type { VersionBlock } from "./reconcile"; + +/** + * The decoded history of a real BlockNote project-status document, captured as + * five successive versions. Each version is a full tree of {@link VersionBlock}s + * carrying *stable* ids, so {@link applyVersion} can derive the minimal ops + * between consecutive versions (insert / update / move / remove) rather than + * rewriting the whole document each step. + * + * This data was decoded from the original Yjs snapshots; it is checked in as + * static data so the example needs no decoder at runtime. + */ +export const VERSIONS: Record< + "v1" | "v2" | "v3" | "v4" | "v5", + VersionBlock[] +> = { + v1: [ + { + id: "initialBlockId", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Budget overview", + }, + ], + }, + { + id: "4b645e0d-6a6f-4510-943d-13dcd6bcebf1", + type: "paragraph", + }, + { + id: "7be49b02-10ec-4a3d-b6ff-1ead154636aa", + type: "paragraph", + }, + { + id: "504bdf5b-5c9c-4435-bd08-d52333d68a7e", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "BlockNote demo", + }, + ], + }, + { + id: "1bf06b34-31d2-4946-8452-fe566f82ba58", + type: "paragraph", + }, + { + id: "bc9d6844-fff9-4af8-a4cb-53f42763a12c", + type: "paragraph", + }, + { + id: "4161db5c-05d3-4451-a8b3-08977cd6f6a3", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Open tasks", + }, + ], + }, + { + id: "c835dcfb-4e83-4b53-880f-f7fb5dc59d20", + type: "paragraph", + }, + { + id: "af1023c2-50e8-4676-9b41-6a4a160d7c46", + type: "paragraph", + }, + ], + v2: [ + { + id: "initialBlockId", + type: "paragraph", + content: [ + { + type: "text", + text: "Goal of document is to look at work ahead and give a status update on project planning in terms of budget and timeline.", + }, + ], + }, + { + id: "62818104-164b-4473-9760-25ffbc55937c", + type: "paragraph", + content: [ + { + type: "text", + text: "(For looking back what has been completed, there are the status updates)", + }, + ], + }, + { + id: "193cbfcd-e467-4377-83b6-2641d042e88d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Budget overview", + }, + ], + }, + { + id: "30c43793-835c-4ce0-b8d3-57748123c644", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Spent March - May", + }, + ], + children: [ + { + id: "256828ef-82d6-4750-8433-423c786b6602", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Kevin: 35k out of 50k", + }, + ], + }, + { + id: "75c50e05-8150-4152-a082-f2fe719b7e63", + type: "bulletListItem", + content: [ + { + type: "text", + text: "BlockNote: 22k out of 50k", + }, + ], + }, + { + id: "d839357b-ab6a-4765-8040-5c72052fc574", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Total: 57k out of 100k (60%)", + }, + ], + }, + ], + }, + { + id: "7990fb3e-7031-482b-bd6b-c7e01bad3cb7", + type: "paragraph", + }, + { + id: "2d0b7eea-2ce0-45d3-9d6c-9990cc043a79", + type: "paragraph", + content: [ + { + type: "text", + text: "Status:", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " at risk 🟠", + }, + ], + }, + { + id: "c79a9782-2dd9-40cf-8fd0-d2d8b6e3dab4", + type: "paragraph", + content: [ + { + type: "text", + text: "+ there's still 40% of budget remaining and currently identified open tasks (see below) should fit this budget (TBD)", + }, + ], + }, + { + id: "c7d7bac7-d9e3-4170-be56-45c8053e9a37", + type: "paragraph", + content: [ + { + type: "text", + text: "- current roadblock (schema compatibility) is taking more time / resources", + }, + ], + }, + { + id: "ecd97b07-682f-40d5-a69a-564117671b96", + type: "paragraph", + content: [ + { + type: "text", + text: '- without a working demo we / client has not been able to start the user-testing phase yet, during which unknown issues could pop up. Therefore, marked as "at risk"', + }, + ], + }, + { + id: "c6c883d5-8174-4cf6-8a29-f4d2f1c81845", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Timeline overview", + }, + ], + }, + { + id: "3d32d32a-ab6a-4d3d-9de0-0d831553ce12", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Original planning aimed for a beta version of suggestions and versioning in BlockNote by June 1st.", + }, + ], + }, + { + id: "a91a9df4-a5bf-4429-8c1d-e87af0f588b0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Status", + styles: { + bold: true, + }, + }, + { + type: "text", + text: ": missed target 🔴 ", + }, + ], + }, + { + id: "504bdf5b-5c9c-4435-bd08-d52333d68a7e", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Schema compatibility", + }, + ], + }, + { + id: "1bf06b34-31d2-4946-8452-fe566f82ba58", + type: "paragraph", + content: [ + { + type: "text", + text: 'The main roadblock we\'re facing at this moment is the current approach to showing "diffs" (critical for both versioning and suggestions) in y-prosemirror developed so-far is incompatible with certain features of Prosemirror: complex schemas. ', + }, + ], + }, + { + id: "cf5fadc8-e2be-45fc-b03f-376533c12af7", + type: "paragraph", + content: [ + { + type: "text", + text: "BlockNote uses a relatively advanced schema to represent nested blocks (child blocks) and thus, we're running into issues setting up a BlockNote demo that goes beyond the basics.", + }, + ], + }, + { + id: "5bd6bff7-42b7-4dce-a6b3-4a67e4449e68", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "Technical explanation", + }, + ], + }, + { + id: "bc9d6844-fff9-4af8-a4cb-53f42763a12c", + type: "paragraph", + content: [ + { + type: "text", + text: "When a user changes a paragraph to a heading, y-prosemirror wants to change the Prosemirror state to the following:", + }, + ], + }, + { + id: "8260bb80-3022-421c-b1fe-050ba69f7234", + type: "paragraph", + }, + { + id: "bbff29b1-4261-4980-bf9a-0b2a29f43317", + type: "codeBlock", + props: { + language: "javascript", + }, + content: [ + { + type: "text", + text: "\nText\nText\n", + }, + ], + }, + { + id: "ba0e68ad-93ca-4e15-8848-01d9fbbcf521", + type: "paragraph", + }, + { + id: "56b0796a-afae-4dc3-a35a-9f4f006cd566", + type: "paragraph", + }, + { + id: "3608d0e2-a750-4849-bf8a-d0afa41ce444", + type: "paragraph", + }, + { + id: "79eed386-42a1-4040-86f6-c97b1c0f2310", + type: "paragraph", + content: [ + { + type: "text", + text: "However, this is not allowed in the BlockNote Prosemirror schema, because ", + }, + { + type: "text", + text: "blockcontainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " can only contain ", + }, + { + type: "text", + text: "blockContent blockgroup?", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (paragraph and heading are blockContent, blockgroup is optional in case there are child blocks). I.e.: a ", + }, + { + type: "text", + text: "BlockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " is allowed to only contain a single node like heading / paragraph.", + }, + ], + }, + { + id: "b1111058-0cac-4256-b575-9c986d8b8745", + type: "paragraph", + }, + { + id: "ac2d4c00-0f0e-4593-b603-8f21f969186a", + type: "paragraph", + content: [ + { + type: "text", + text: "The past +-2 weeks we've explored several ways to work around these issues (see ", + }, + { + type: "link", + href: "https://docs.blocknotejs.mosacloud.eu/docs/d4846e43-a647-42ba-ab14-b9f6031437c3/", + content: [ + { + type: "text", + text: "doc", + styles: {}, + }, + ], + }, + { + type: "text", + text: "). Broadly, remedies come down to:", + }, + ], + }, + { + id: "aa70b19a-d0b8-44c1-ac1f-a1049a057227", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "A: Change architecture of BlockNote", + }, + ], + }, + { + id: "82e90c8a-d18b-4847-a17a-46d7abc7b78a", + type: "paragraph", + content: [ + { + type: "text", + text: 'Change BlockNote in such a way that we relax the schema so "diffing nodes" (', + }, + { + type: "text", + text: "heading old", + styles: { + code: true, + }, + }, + { + type: "text", + text: " in the example) are allowed in the document. For example, we could:", + }, + ], + }, + { + id: "d7009d83-7fd6-4611-92ea-50f8141051c8", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Allow special "diffing nodes" within blockContainer', + }, + ], + }, + { + id: "3fddd740-1615-4fd9-bf27-a044ce7dc394", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Flatten the BlockNote PM schema as much as possible. For example, instead of using a tree-based structure to represent children / nesting, keep blocks in a flat array and use an ", + }, + { + type: "text", + text: "indentation", + styles: { + code: true, + }, + }, + { + type: "text", + text: " for nesting", + }, + ], + children: [ + { + id: "893ef8a9-a4b3-4346-8d5b-85a8c2fab90e", + type: "bulletListItem", + content: [ + { + type: "text", + text: "(this might actually have other benefits in terms of conflict-resolution or the ability to do word / google docs style multi-tab indentation)", + }, + ], + }, + ], + }, + { + id: "a468194d-5c50-48c7-a6c4-c9ca3d9c2b66", + type: "paragraph", + }, + { + id: "e5fb5ec6-a5a7-4b8e-b3ff-2e01804c80ce", + type: "paragraph", + content: [ + { + type: "text", + text: "While feasible, this would affect almost all parts of the code base that interact with Prosemirror nodes, and would likely be a multi-week refactor.", + }, + ], + }, + { + id: "9b328ccb-a58c-4804-a13c-3cdd615235cd", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "B: Change architecture of binding", + }, + ], + }, + { + id: "43dd66b5-ecf0-4838-b139-df511598d0d1", + type: "paragraph", + }, + { + id: "0a474fef-8e96-4913-8f08-f26bb90e7dbd", + type: "paragraph", + content: [ + { + type: "text", + text: "Instead of having y-prosemirror output diffing information directly in the Prosemirror state, information about diffs would be emitted as metadata separately. The editor (BlockNote) will then be responsible for rendering the diffs, likely using Prosemirror decorations.", + }, + ], + }, + { + id: "2625a3f7-122b-46c8-bed7-703707630275", + type: "paragraph", + }, + { + id: "95701bcc-72b4-4111-9b43-363603bd51da", + type: "paragraph", + content: [ + { + type: "text", + text: "This is a major architectural shift from how y-prosemirror currently works. Estimated effort: ???", + }, + ], + }, + { + id: "35ee260e-7b74-430b-a3bc-a59b507b0481", + type: "paragraph", + }, + { + id: "5a2ba2d1-e34c-4b88-a19e-820469913404", + type: "paragraph", + content: [ + { + type: "text", + text: "There would also be some downsides. For example, it's not feasible to allow typing / formatting content that's marked as deleted in this case (something that's possible in other software, though we can challenge how valuable it is?)", + }, + ], + }, + { + id: "7e452494-ec6a-42dd-b6c2-3c4d659572fc", + type: "paragraph", + }, + { + id: "614dd3ae-6a51-4694-bcc7-7f616d19e0c1", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "cd742d02-63f2-48a3-a8f1-dd87aab04d0d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Consumers don't need to change schema", + }, + ], + }, + { + id: "48c0afec-90b3-4cd1-a75e-b188eefc9612", + type: "paragraph", + }, + { + id: "f627747c-b25d-4918-8fc1-b3fcb0ad9d98", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "66368e55-a4d1-4618-9f53-4dc0829bce5c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Can't edit deleted content", + }, + ], + }, + { + id: "0bde15ed-8569-4149-a769-ec5bced4d956", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No cursors in deleted content", + }, + ], + }, + { + id: "678a0e9b-3db7-434a-a395-30a4d102ed1c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to render all attributed content separately (transform to dom)", + }, + ], + }, + { + id: "29ed238e-ff18-4dc3-b3c0-ce0560442532", + type: "bulletListItem", + content: [ + { + type: "text", + text: "BlockNote has little control over how content is rendered", + }, + ], + }, + { + id: "033bc3e1-c876-4594-9e98-681989301dcc", + type: "paragraph", + }, + { + id: "d7d7f768-5619-496a-9845-7b8ef49b75f1", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C: Use current architecture, but control where diffs are rendered", + }, + ], + }, + { + id: "151c0cc8-2782-4b4f-a995-92c80692aadb", + type: "paragraph", + content: [ + { + type: "text", + text: "Before choosing option A or B, we can explore alternatives that use the current architecture of both y-prosemirror and BlockNote.", + }, + ], + }, + { + id: "d6cabd24-09c9-4fd4-becd-8516cf77725a", + type: "paragraph", + content: [ + { + type: "text", + text: "This is currently WIP", + styles: { + italic: true, + }, + }, + ], + }, + { + id: "6242b56a-5f2c-4f47-b71e-b9b5de6b0769", + type: "paragraph", + }, + { + id: "a97ea356-1d8f-4881-aae7-3ae39a76d54d", + type: "paragraph", + }, + { + id: "d747a50e-577f-4fef-8986-34087444f091", + type: "heading", + props: { + level: 4, + }, + content: [ + { + type: "text", + text: "yjs <-> PM custom transforms", + }, + ], + }, + { + id: "71790421-d87a-4fda-8750-5918ecb30de9", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "613a6bc8-2df0-4a81-be5d-844672405634", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Likely a good solution to the problem without too much overhaul", + }, + ], + }, + { + id: "1c597102-4c8a-42fb-8161-547ce78b3327", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Can improve "conflict resolution" of some other operations (e.g.: multiple users create a child block)', + }, + ], + }, + { + id: "09c694da-9118-43a6-8ca6-efc99f72d18c", + type: "paragraph", + }, + { + id: "c730ff68-41bd-4817-b5b7-d61cdb10b291", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "195f16d9-5d4f-48ac-89a2-9a02c457b28f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to be very delicate about how to allow this functionality (how to expose it from y-prosemirror)", + }, + ], + children: [ + { + id: "77124308-3a25-4204-a22c-bd8d64f96b31", + type: "bulletListItem", + content: [ + { + type: "text", + text: "For example: only allow transforming certain nodes in a safe manner: e.g. ", + }, + { + type: "text", + text: "", + styles: { + code: true, + }, + }, + { + type: "text", + text: " ↦ ", + }, + { + type: "text", + text: '<_block type="paragraph"', + styles: { + code: true, + }, + }, + { + type: "text", + text: " .", + }, + ], + }, + ], + }, + { + id: "927b24ee-8c08-4262-95a1-315a52cadf47", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Requires data migration", + }, + ], + }, + { + id: "614285cb-2e36-44a5-81fa-75268f9a9976", + type: "paragraph", + }, + { + id: "469eb25c-0bac-4744-9d98-1d0d3b1354f1", + type: "paragraph", + }, + { + id: "1a0b96f9-b364-4264-a1c9-b93de53191a2", + type: "paragraph", + }, + { + id: "5e72c1e9-1cfe-47cb-8db1-d155a5284e4e", + type: "paragraph", + content: [ + { + type: "text", + text: " ", + }, + ], + }, + { + id: "4161db5c-05d3-4451-a8b3-08977cd6f6a3", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Open tasks", + }, + ], + }, + { + id: "62986f28-3a36-4702-83f8-194a6a805dc0", + type: "paragraph", + content: [ + { + type: "text", + text: "The currently scoped remaining work has been categorized in 5 phases:", + }, + ], + }, + { + id: "bd772ad8-b12d-42d2-8082-be58366cbc3b", + type: "paragraph", + }, + { + id: "de0e3f48-8b39-44f4-8766-c8344dc97d79", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "1: Demo readiness", + }, + ], + }, + { + id: "cf5b7f55-53fe-4847-9ff9-2adff6beeee6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Demo+readiness%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4cd9804b-f320-4cfe-83a2-a25c46066104", + type: "paragraph", + }, + { + id: "6ca5e395-6654-4b05-b616-ef3bcdf96239", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Get the current work to a demoable and testable state", + }, + ], + }, + { + id: "c009551c-2f98-4ea8-8654-404e6b7444ac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "66971ae0-91f7-4c42-9c23-2e679527ab45", + type: "bulletListItem", + content: [ + { + type: "text", + text: "schema compatibility", + }, + ], + }, + { + id: "c9ca8f7f-3a08-46cb-90bc-dc5696d7f260", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TO DISCUSS", + }, + ], + }, + ], + }, + { + id: "a8bef0dc-6061-4141-aa62-7fdf9a15ce2a", + type: "paragraph", + }, + { + id: "0b33469a-e047-4e86-846f-ee820583ce82", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "2: Stability", + }, + ], + }, + { + id: "58b960dd-d5f8-4af3-a8a7-37ffaebd3611", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Stability+%28diffs+%2F+versions%29%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "040949d3-65c5-43e6-ae57-a8f27c5c9f73", + type: "paragraph", + }, + { + id: "7937911d-a79c-4774-af90-67b342c7fa23", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Fix known issues in the current y-prosemirror binding", + }, + ], + }, + { + id: "0d7025db-3eb1-4bfc-b693-e885b4d60390", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "ce732233-9a30-44ee-8c8c-66340ae3c121", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Add support for Table diffs to BlockNote and y-prosemirror", + }, + ], + children: [ + { + id: "df7e6725-2bb8-4ce0-b84c-e36f8586bde6", + type: "bulletListItem", + content: [ + { + type: "text", + text: "This has some unknowns and potentially needs a number of changes to ", + }, + { + type: "text", + text: "prosemirror-tables", + styles: { + code: true, + }, + }, + ], + }, + ], + }, + { + id: "18b5622c-76e3-4e41-9659-820801967147", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Potential new items after testing demo", + }, + ], + }, + { + id: "5bc10985-b891-4217-8dd5-45d53a477cf9", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TO DISCUSS", + }, + ], + }, + ], + }, + { + id: "234b4ee0-0024-4dea-86bf-47b961c8dd6f", + type: "paragraph", + }, + { + id: "f2eb1b59-3909-4a6c-af47-b31662cabeae", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "3: BlockNote level features", + }, + ], + }, + { + id: "a959c626-b4bc-4efa-af6e-b15aedb177b6", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Implement history panel", + }, + ], + }, + { + id: "14138376-912f-428f-ab74-643652c6bb62", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Update BlockNote APIs and documentation, make existing BlockNote APIs compatible with "diff views"', + }, + ], + }, + { + id: "69af46e1-d7c2-436f-8bae-dae836d3ae96", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "6787c2bc-28d5-4cf6-9bb0-1d05aceefddb", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + { + id: "37fa84cf-570a-411a-9e96-c9e3bc9113d0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TO DISCUSS", + }, + ], + }, + ], + }, + { + id: "81363d76-5daa-44f1-ac79-61b967f193db", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "4: Rollout", + }, + ], + }, + { + id: "ae6ac180-2492-41c6-9abb-2ae4dc00d4df", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Release+%2F+rollout%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4e3ee5c0-c2b5-4258-880e-da5b418e9826", + type: "paragraph", + }, + { + id: "c6c65714-7d1b-43fc-8d23-5f6a452b4f18", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Migration guide", + }, + ], + }, + { + id: "361cbb94-ec74-482a-8c08-e2b938183064", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Release of y-prosemirror", + }, + ], + }, + { + id: "2eed8480-da1e-4f64-90ba-3ccf6a9df08f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Release of BlockNote with (optional) new Yjs / y-prosemirror compatibility", + }, + ], + }, + { + id: "19b84b6a-5c1e-4637-917f-57282cff6612", + type: "paragraph", + }, + { + id: "e350b6ae-c8ff-4968-9149-419b08328923", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "5f7423fe-81d4-4a5b-8c1e-b9797308ec2b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + { + id: "b5eab023-1223-4fd9-817f-3dbca47c5e7d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TO DISCUSS", + }, + ], + }, + ], + }, + { + id: "eade368f-5d90-49d6-b012-42e873d2dddf", + type: "paragraph", + }, + { + id: "ab044a82-f38c-459a-99c7-342bb176e556", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "5: Suggestions", + }, + ], + }, + { + id: "0e9ae6b9-85b1-4164-a137-e14323edb349", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Suggestions+%28track+changes%29%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "2b12f097-fe31-43dd-97db-6bea1e33dfa2", + type: "paragraph", + }, + { + id: "e8cca260-bc7e-4a2c-834d-89a7d337e336", + type: "paragraph", + content: [ + { + type: "text", + text: "Specific features related to suggestions / track changes.", + }, + ], + }, + { + id: "116548de-8362-432b-af2c-af2702e16d5e", + type: "paragraph", + }, + { + id: "6476312d-aa12-4e5b-a093-bd36b90fddca", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "357f34c3-599e-4068-a196-83cc6930f2f0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "bugs in typing / editing suggestions", + }, + ], + }, + { + id: "3d5f3c55-5c98-4cd2-8aab-e0160df3859f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "commenting on suggestions / sidebar", + }, + ], + }, + { + id: "e9420d7f-a9df-4491-ae92-72d95725010a", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TO DISCUSS", + }, + ], + }, + ], + }, + { + id: "c7e9c438-e13c-4a3d-811e-f057c53dc3cf", + type: "paragraph", + }, + ], + v3: [ + { + id: "initialBlockId", + type: "paragraph", + content: [ + { + type: "text", + text: "Goal of document is to look at work ahead and give a status update on project planning in terms of budget and timeline.", + }, + ], + }, + { + id: "62818104-164b-4473-9760-25ffbc55937c", + type: "paragraph", + content: [ + { + type: "text", + text: "(For looking back what has been completed, there are the status updates)", + }, + ], + }, + { + id: "193cbfcd-e467-4377-83b6-2641d042e88d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Budget overview", + }, + ], + }, + { + id: "30c43793-835c-4ce0-b8d3-57748123c644", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Spent March - May", + }, + ], + children: [ + { + id: "256828ef-82d6-4750-8433-423c786b6602", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Kevin: 35k out of 50k", + }, + ], + }, + { + id: "75c50e05-8150-4152-a082-f2fe719b7e63", + type: "bulletListItem", + content: [ + { + type: "text", + text: "BlockNote: 22k out of 50k", + }, + ], + }, + { + id: "d839357b-ab6a-4765-8040-5c72052fc574", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Total: 57k out of 100k (60%)", + }, + ], + }, + ], + }, + { + id: "7990fb3e-7031-482b-bd6b-c7e01bad3cb7", + type: "paragraph", + }, + { + id: "2d0b7eea-2ce0-45d3-9d6c-9990cc043a79", + type: "paragraph", + content: [ + { + type: "text", + text: "Status:", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " at risk 🟠", + }, + ], + }, + { + id: "c79a9782-2dd9-40cf-8fd0-d2d8b6e3dab4", + type: "paragraph", + content: [ + { + type: "text", + text: "+ there's still 40% of budget remaining and currently identified open tasks (see below) should fit this budget (TBD)", + }, + ], + }, + { + id: "c7d7bac7-d9e3-4170-be56-45c8053e9a37", + type: "paragraph", + content: [ + { + type: "text", + text: "- current roadblock (schema compatibility) is taking more time / resources", + }, + ], + }, + { + id: "ecd97b07-682f-40d5-a69a-564117671b96", + type: "paragraph", + content: [ + { + type: "text", + text: '- without a working demo we / client has not been able to start the user-testing phase yet, during which unknown issues could pop up. Therefore, marked as "at risk"', + }, + ], + }, + { + id: "6e400e2c-3e9e-4334-886d-d9f41c59f720", + type: "paragraph", + }, + { + id: "53aac82d-99ce-498e-8bc1-b932fe52ff1e", + type: "paragraph", + content: [ + { + type: "text", + text: "TODO: keep within budget?", + }, + ], + }, + { + id: "c6c883d5-8174-4cf6-8a29-f4d2f1c81845", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Timeline overview", + }, + ], + }, + { + id: "3d32d32a-ab6a-4d3d-9de0-0d831553ce12", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Original planning aimed for a beta version of suggestions and versioning in BlockNote by June 1st.", + }, + ], + }, + { + id: "a91a9df4-a5bf-4429-8c1d-e87af0f588b0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Status", + styles: { + bold: true, + }, + }, + { + type: "text", + text: ": missed target 🔴 ", + }, + ], + }, + { + id: "b3558b5b-a480-4a01-a279-69d7df6978b2", + type: "paragraph", + }, + { + id: "3a4d7e08-998c-48e8-bae1-664e8aaf9068", + type: "paragraph", + content: [ + { + type: "text", + text: "TODO: what's new timeline?", + }, + ], + }, + { + id: "504bdf5b-5c9c-4435-bd08-d52333d68a7e", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Schema compatibility", + }, + ], + }, + { + id: "1bf06b34-31d2-4946-8452-fe566f82ba58", + type: "paragraph", + content: [ + { + type: "text", + text: 'The main roadblock we\'re facing at this moment is the current approach to showing "diffs" (critical for both versioning and suggestions) in y-prosemirror developed so-far is incompatible with certain features of Prosemirror: complex schemas. ', + }, + ], + }, + { + id: "cf5fadc8-e2be-45fc-b03f-376533c12af7", + type: "paragraph", + content: [ + { + type: "text", + text: "BlockNote uses a relatively advanced schema to represent nested blocks (child blocks) and thus, we're running into issues setting up a BlockNote demo that goes beyond the basics.", + }, + ], + }, + { + id: "5bd6bff7-42b7-4dce-a6b3-4a67e4449e68", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "Technical explanation", + }, + ], + }, + { + id: "bc9d6844-fff9-4af8-a4cb-53f42763a12c", + type: "paragraph", + content: [ + { + type: "text", + text: "When a user changes a paragraph to a heading, y-prosemirror wants to change the Prosemirror state to the following:", + }, + ], + }, + { + id: "8260bb80-3022-421c-b1fe-050ba69f7234", + type: "paragraph", + }, + { + id: "bbff29b1-4261-4980-bf9a-0b2a29f43317", + type: "codeBlock", + props: { + language: "javascript", + }, + content: [ + { + type: "text", + text: "\nText\nText\n", + }, + ], + }, + { + id: "ba0e68ad-93ca-4e15-8848-01d9fbbcf521", + type: "paragraph", + }, + { + id: "79eed386-42a1-4040-86f6-c97b1c0f2310", + type: "paragraph", + content: [ + { + type: "text", + text: "However, this is not allowed in the BlockNote Prosemirror schema, because ", + }, + { + type: "text", + text: "blockcontainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " can only contain ", + }, + { + type: "text", + text: "blockContent blockgroup?", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (paragraph and heading are blockContent, blockgroup is optional in case there are child blocks). I.e.: a ", + }, + { + type: "text", + text: "BlockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " is allowed to only contain a single node like heading / paragraph.", + }, + ], + }, + { + id: "b1111058-0cac-4256-b575-9c986d8b8745", + type: "paragraph", + }, + { + id: "ac2d4c00-0f0e-4593-b603-8f21f969186a", + type: "paragraph", + content: [ + { + type: "text", + text: "The past +-2 weeks we've explored several ways to work around these issues (see ", + }, + { + type: "link", + href: "https://docs.blocknotejs.mosacloud.eu/docs/d4846e43-a647-42ba-ab14-b9f6031437c3/", + content: [ + { + type: "text", + text: "doc", + styles: {}, + }, + ], + }, + { + type: "text", + text: "). Broadly, remedies come down to:", + }, + ], + }, + { + id: "4a7e88d5-e945-421e-bb9b-0901856aca75", + type: "paragraph", + }, + { + id: "aa70b19a-d0b8-44c1-ac1f-a1049a057227", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "A: Change architecture of BlockNote", + }, + ], + }, + { + id: "82e90c8a-d18b-4847-a17a-46d7abc7b78a", + type: "paragraph", + content: [ + { + type: "text", + text: 'Change BlockNote in such a way that we relax the schema so "diffing nodes" (', + }, + { + type: "text", + text: "heading old", + styles: { + code: true, + }, + }, + { + type: "text", + text: " in the example) are allowed in the document. For example, we could:", + }, + ], + }, + { + id: "d7009d83-7fd6-4611-92ea-50f8141051c8", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Allow special "diffing nodes" within blockContainer', + }, + ], + }, + { + id: "3fddd740-1615-4fd9-bf27-a044ce7dc394", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Flatten the BlockNote PM schema as much as possible. For example, instead of using a tree-based structure to represent children / nesting, keep blocks in a flat array and use an ", + }, + { + type: "text", + text: "indentation", + styles: { + code: true, + }, + }, + { + type: "text", + text: " for nesting", + }, + ], + children: [ + { + id: "893ef8a9-a4b3-4346-8d5b-85a8c2fab90e", + type: "bulletListItem", + content: [ + { + type: "text", + text: "(this might actually have other benefits in terms of conflict-resolution or the ability to do word / google docs style multi-tab indentation)", + }, + ], + }, + ], + }, + { + id: "a468194d-5c50-48c7-a6c4-c9ca3d9c2b66", + type: "paragraph", + }, + { + id: "e5fb5ec6-a5a7-4b8e-b3ff-2e01804c80ce", + type: "paragraph", + content: [ + { + type: "text", + text: "While feasible, this would affect almost all parts of the code base that interact with Prosemirror nodes, and would likely be a multi-week refactor.", + }, + ], + }, + { + id: "9b328ccb-a58c-4804-a13c-3cdd615235cd", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "B: Change architecture of binding", + }, + ], + }, + { + id: "0a474fef-8e96-4913-8f08-f26bb90e7dbd", + type: "paragraph", + content: [ + { + type: "text", + text: "Instead of having y-prosemirror output diffing information directly in the Prosemirror state, information about diffs would be emitted as metadata separately. The editor (BlockNote) will then be responsible for rendering the diffs, likely using Prosemirror decorations.", + }, + ], + }, + { + id: "2625a3f7-122b-46c8-bed7-703707630275", + type: "paragraph", + }, + { + id: "95701bcc-72b4-4111-9b43-363603bd51da", + type: "paragraph", + content: [ + { + type: "text", + text: "This is a major architectural shift from how y-prosemirror currently works. Estimated effort: ???", + }, + ], + }, + { + id: "35ee260e-7b74-430b-a3bc-a59b507b0481", + type: "paragraph", + }, + { + id: "5a2ba2d1-e34c-4b88-a19e-820469913404", + type: "paragraph", + content: [ + { + type: "text", + text: "There would also be some downsides. For example, it's not feasible to allow typing / formatting content that's marked as deleted in this case (something that's possible in other software, though we can challenge how valuable it is?)", + }, + ], + }, + { + id: "7e452494-ec6a-42dd-b6c2-3c4d659572fc", + type: "paragraph", + }, + { + id: "614dd3ae-6a51-4694-bcc7-7f616d19e0c1", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "cd742d02-63f2-48a3-a8f1-dd87aab04d0d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Consumers don't need to change schema", + }, + ], + children: [ + { + id: "839bedaa-5c25-4e47-a5bc-3fa80f53c632", + type: "bulletListItem", + content: [ + { + type: "text", + text: "just works for everyone", + }, + ], + }, + ], + }, + { + id: "48c0afec-90b3-4cd1-a75e-b188eefc9612", + type: "paragraph", + }, + { + id: "f627747c-b25d-4918-8fc1-b3fcb0ad9d98", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "66368e55-a4d1-4618-9f53-4dc0829bce5c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Can't edit deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0bde15ed-8569-4149-a769-ec5bced4d956", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No cursors in deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "f8646c47-b339-4746-a93b-855071dfa16f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Not possible to comment on deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "12ab366f-fc07-4927-9d5b-d25efb2228ae", + type: "bulletListItem", + content: [ + { + type: "text", + text: "tables?", + }, + ], + }, + { + id: "678a0e9b-3db7-434a-a395-30a4d102ed1c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to render all attributed content separately (transform to dom)", + }, + ], + }, + { + id: "d7d7f768-5619-496a-9845-7b8ef49b75f1", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C: Use current architecture, but control where diffs are rendered", + }, + ], + }, + { + id: "151c0cc8-2782-4b4f-a995-92c80692aadb", + type: "paragraph", + content: [ + { + type: "text", + text: "Before choosing option A or B, we can explore alternatives that use the current architecture of both y-prosemirror and BlockNote.", + }, + ], + }, + { + id: "d6cabd24-09c9-4fd4-becd-8516cf77725a", + type: "paragraph", + content: [ + { + type: "text", + text: "This is currently WIP", + styles: { + italic: true, + }, + }, + ], + }, + { + id: "a97ea356-1d8f-4881-aae7-3ae39a76d54d", + type: "paragraph", + }, + { + id: "d747a50e-577f-4fef-8986-34087444f091", + type: "heading", + props: { + level: 4, + }, + content: [ + { + type: "text", + text: "yjs <-> PM custom transforms", + }, + ], + }, + { + id: "71790421-d87a-4fda-8750-5918ecb30de9", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "613a6bc8-2df0-4a81-be5d-844672405634", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Likely a good solution to the problem without too much overhaul", + }, + ], + }, + { + id: "1c597102-4c8a-42fb-8161-547ce78b3327", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Can improve "conflict resolution" of some other operations (e.g.: multiple users create a child block)', + }, + ], + }, + { + id: "09c694da-9118-43a6-8ca6-efc99f72d18c", + type: "paragraph", + }, + { + id: "c730ff68-41bd-4817-b5b7-d61cdb10b291", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "195f16d9-5d4f-48ac-89a2-9a02c457b28f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to be very delicate about how to allow this functionality (how to expose it from y-prosemirror)", + }, + ], + children: [ + { + id: "77124308-3a25-4204-a22c-bd8d64f96b31", + type: "bulletListItem", + content: [ + { + type: "text", + text: "For example: only allow transforming certain nodes in a safe manner: e.g. ", + }, + { + type: "text", + text: "", + styles: { + code: true, + }, + }, + { + type: "text", + text: " ↦ ", + }, + { + type: "text", + text: '<_block type="paragraph"', + styles: { + code: true, + }, + }, + { + type: "text", + text: " .", + }, + ], + }, + ], + }, + { + id: "927b24ee-8c08-4262-95a1-315a52cadf47", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Requires data migration", + }, + ], + }, + { + id: "5e72c1e9-1cfe-47cb-8db1-d155a5284e4e", + type: "paragraph", + content: [ + { + type: "text", + text: " ", + }, + ], + }, + { + id: "4161db5c-05d3-4451-a8b3-08977cd6f6a3", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Open tasks", + }, + ], + }, + { + id: "62986f28-3a36-4702-83f8-194a6a805dc0", + type: "paragraph", + content: [ + { + type: "text", + text: "The currently scoped remaining work has been categorized in 5 phases:", + }, + ], + }, + { + id: "bd772ad8-b12d-42d2-8082-be58366cbc3b", + type: "paragraph", + }, + { + id: "de0e3f48-8b39-44f4-8766-c8344dc97d79", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "1: Demo readiness", + }, + ], + }, + { + id: "cf5b7f55-53fe-4847-9ff9-2adff6beeee6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Demo+readiness%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4cd9804b-f320-4cfe-83a2-a25c46066104", + type: "paragraph", + }, + { + id: "6ca5e395-6654-4b05-b616-ef3bcdf96239", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Get the current work to a demoable and testable state", + }, + ], + }, + { + id: "c009551c-2f98-4ea8-8654-404e6b7444ac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "66971ae0-91f7-4c42-9c23-2e679527ab45", + type: "bulletListItem", + content: [ + { + type: "text", + text: "schema compatibility", + }, + ], + }, + { + id: "d328f8ed-1a83-489c-bca4-79bdf044fbac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Add support for Table diffs to BlockNote and y-prosemirror", + }, + ], + children: [ + { + id: "f2d81783-ccb4-4d65-b94c-11c168899607", + type: "bulletListItem", + content: [ + { + type: "text", + text: "This has some unknowns and potentially needs a number of changes to ", + }, + { + type: "text", + text: "prosemirror-tables", + styles: { + code: true, + }, + }, + ], + }, + ], + }, + ], + }, + { + id: "c9ca8f7f-3a08-46cb-90bc-dc5696d7f260", + type: "paragraph", + }, + { + id: "e41f3d25-e219-4b3c-9f4b-faf64ba214d4", + type: "paragraph", + content: [ + { + type: "text", + text: "Estimate: depends on schema next step", + }, + ], + }, + { + id: "0b33469a-e047-4e86-846f-ee820583ce82", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "2: Stability", + }, + ], + }, + { + id: "58b960dd-d5f8-4af3-a8a7-37ffaebd3611", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Stability+%28diffs+%2F+versions%29%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "040949d3-65c5-43e6-ae57-a8f27c5c9f73", + type: "paragraph", + }, + { + id: "7937911d-a79c-4774-af90-67b342c7fa23", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Fix known issues in the current y-prosemirror binding", + }, + ], + }, + { + id: "0d7025db-3eb1-4bfc-b693-e885b4d60390", + type: "bulletListItem", + content: [ + { + type: "text", + text: "y-prosemirror at level that it's comfortable to release as new major version", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0981d2e1-9ab3-48f8-b1a1-4365a548b2b8", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TODO Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "18b5622c-76e3-4e41-9659-820801967147", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Potential new items after testing demo", + }, + ], + }, + { + id: "5bc10985-b891-4217-8dd5-45d53a477cf9", + type: "paragraph", + }, + ], + }, + { + id: "e67ccfc3-10c1-4fb1-80a8-f0ffaf03ff91", + type: "paragraph", + }, + { + id: "625026e4-198e-4ad7-ab64-f884e82aaf9a", + type: "paragraph", + content: [ + { + type: "text", + text: "Initial estimate Kevin: 5-8 days + ??? for unknowns", + }, + ], + }, + { + id: "19c4f7cb-0c2e-4fe7-b25c-af9f31fb0aba", + type: "paragraph", + }, + { + id: "eea91bef-61f2-4ac8-9d2c-559fdc528a30", + type: "paragraph", + content: [ + { + type: "text", + text: "2 XS", + }, + ], + }, + { + id: "5e3740c6-cee0-4e53-a6c0-8c55a7864ce5", + type: "paragraph", + content: [ + { + type: "text", + text: "2 S", + }, + ], + }, + { + id: "1cc8ee7b-0e9d-43bd-9f10-68e5e2295b73", + type: "paragraph", + content: [ + { + type: "text", + text: "3 M", + }, + ], + }, + { + id: "8b19fecc-ceed-4139-99dd-5bac14990fd4", + type: "paragraph", + content: [ + { + type: "text", + text: "1 L", + }, + ], + }, + { + id: "2e1b9234-38a5-4e07-98b3-1c2196054d88", + type: "paragraph", + }, + { + id: "5b16f492-2b25-400c-987b-97b8a5eb4c90", + type: "paragraph", + content: [ + { + type: "text", + text: "Counted estimate: 2+(3-6)+(2-5) = 6-13 days + ??? for unknowns", + }, + ], + }, + { + id: "f2eb1b59-3909-4a6c-af47-b31662cabeae", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "3: BlockNote level features", + }, + ], + }, + { + id: "49904179-8e10-4770-9587-524966c4581c", + type: "paragraph", + }, + { + id: "a959c626-b4bc-4efa-af6e-b15aedb177b6", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Implement history panel", + }, + ], + }, + { + id: "14138376-912f-428f-ab74-643652c6bb62", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Update BlockNote APIs and documentation, make existing BlockNote APIs compatible with "diff views"', + }, + ], + }, + { + id: "69af46e1-d7c2-436f-8bae-dae836d3ae96", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "6787c2bc-28d5-4cf6-9bb0-1d05aceefddb", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "37fa84cf-570a-411a-9e96-c9e3bc9113d0", + type: "paragraph", + }, + { + id: "a450596d-8901-4712-8f3c-d4425a53d72b", + type: "paragraph", + content: [ + { + type: "text", + text: "1 L", + }, + ], + }, + { + id: "540c68ec-944b-45a0-9f79-4e1591b98af1", + type: "paragraph", + content: [ + { + type: "text", + text: "2 M", + }, + ], + }, + { + id: "f89a92e8-6742-416f-af07-b65c099b7718", + type: "paragraph", + }, + { + id: "859ed9e8-be8d-4582-ac11-f7195a5f1f7c", + type: "paragraph", + content: [ + { + type: "text", + text: "= 4-9 days", + }, + ], + }, + { + id: "2cb75462-1df9-4f94-bf7b-4ebb3de96fbb", + type: "paragraph", + }, + { + id: "81363d76-5daa-44f1-ac79-61b967f193db", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "4: Rollout", + }, + ], + }, + { + id: "ae6ac180-2492-41c6-9abb-2ae4dc00d4df", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Release+%2F+rollout%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4e3ee5c0-c2b5-4258-880e-da5b418e9826", + type: "paragraph", + }, + { + id: "c6c65714-7d1b-43fc-8d23-5f6a452b4f18", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Migration guide", + }, + ], + }, + { + id: "361cbb94-ec74-482a-8c08-e2b938183064", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Stable release of y-prosemirror + yjs + lib0", + }, + ], + children: [ + { + id: "d010e74c-d72f-4d11-9d96-fac784b9ec6a", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Planned for end of August", + }, + ], + }, + ], + }, + { + id: "2eed8480-da1e-4f64-90ba-3ccf6a9df08f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Release of BlockNote with (optional) new Yjs / y-prosemirror compatibility", + }, + ], + }, + { + id: "19b84b6a-5c1e-4637-917f-57282cff6612", + type: "paragraph", + }, + { + id: "e350b6ae-c8ff-4968-9149-419b08328923", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "5f7423fe-81d4-4a5b-8c1e-b9797308ec2b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "b5eab023-1223-4fd9-817f-3dbca47c5e7d", + type: "paragraph", + }, + { + id: "eade368f-5d90-49d6-b012-42e873d2dddf", + type: "paragraph", + content: [ + { + type: "text", + text: "3 M", + }, + ], + }, + { + id: "569e9d88-2901-41df-acb0-f1b631012df3", + type: "paragraph", + content: [ + { + type: "text", + text: "1 S", + }, + ], + }, + { + id: "1c415b65-68b1-4ab3-adf4-7e0c903a9232", + type: "paragraph", + }, + { + id: "630aaf9f-de91-4643-a2af-8e47f1c67ef2", + type: "paragraph", + content: [ + { + type: "text", + text: "= 3.5 - 6.5 days", + }, + ], + }, + { + id: "ab044a82-f38c-459a-99c7-342bb176e556", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "5: Suggestions", + }, + ], + }, + { + id: "0e9ae6b9-85b1-4164-a137-e14323edb349", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Suggestions+%28track+changes%29%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "2b12f097-fe31-43dd-97db-6bea1e33dfa2", + type: "paragraph", + }, + { + id: "e8cca260-bc7e-4a2c-834d-89a7d337e336", + type: "paragraph", + content: [ + { + type: "text", + text: "Specific features related to suggestions / track changes.", + }, + ], + }, + { + id: "116548de-8362-432b-af2c-af2702e16d5e", + type: "paragraph", + }, + { + id: "6476312d-aa12-4e5b-a093-bd36b90fddca", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "357f34c3-599e-4068-a196-83cc6930f2f0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "delete suggestions", + }, + ], + }, + ], + }, + { + id: "3fe5c25b-87a8-4b97-bb3f-675bce4a7848", + type: "paragraph", + }, + { + id: "f2e94dbf-07ea-4f37-9a3c-438b0431618d", + type: "paragraph", + content: [ + { + type: "text", + text: "1 XL", + }, + ], + }, + { + id: "d13e3a8e-8239-40fc-ba89-7a9c57af2c88", + type: "paragraph", + content: [ + { + type: "text", + text: "3 L", + }, + ], + }, + { + id: "2822cf62-665d-4d82-bfe3-3e6d9b87419f", + type: "paragraph", + content: [ + { + type: "text", + text: "4 M", + }, + ], + }, + { + id: "596a8524-457a-41e8-b715-042adc217db2", + type: "paragraph", + content: [ + { + type: "text", + text: "2 S", + }, + ], + }, + { + id: "c24c882a-b16a-41ea-b7dd-20c057777353", + type: "paragraph", + }, + { + id: "057da6a6-7b99-478e-8629-14efcad4028d", + type: "paragraph", + content: [ + { + type: "text", + text: "= (6-15)+(4-8)+1 = 11-24 days", + }, + ], + }, + { + id: "359ceed1-3958-43ab-8143-73cc4f588053", + type: "heading", + content: [ + { + type: "text", + text: "Next steps", + }, + ], + }, + { + id: "2ad8d758-a513-4402-9626-c1283ec39254", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Y: clean up above + count estimates", + }, + ], + }, + { + id: "ca22776b-07b7-4ab2-ad9c-fd153123120a", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Y: Sync with Virgile", + }, + ], + }, + { + id: "fe5335ee-132a-47c9-8a00-e1d6cc2dc095", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Decide on schema next steps", + }, + ], + children: [ + { + id: "2f14a5d4-7864-4b84-82e9-a0b7f6ffe96f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Kevin: share exploration A", + }, + ], + }, + { + id: "fa6e98bf-b093-4fe8-8d17-e1c58457295b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Y: share C", + }, + ], + }, + { + id: "c88e5981-afa2-4705-b861-b1bec3ce8908", + type: "bulletListItem", + content: [ + { + type: "text", + text: "N: share B", + }, + ], + }, + ], + }, + { + id: "f5bbf437-ff39-4796-9b1d-0ac5a3381764", + type: "paragraph", + }, + { + id: "c7e9c438-e13c-4a3d-811e-f057c53dc3cf", + type: "paragraph", + }, + ], + v4: [ + { + id: "initialBlockId", + type: "paragraph", + }, + { + id: "62818104-164b-4473-9760-25ffbc55937c", + type: "paragraph", + content: [ + { + type: "text", + text: "(For looking back what has been completed, there are the status updates)", + }, + ], + }, + { + id: "193cbfcd-e467-4377-83b6-2641d042e88d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Budget overview", + }, + ], + }, + { + id: "30c43793-835c-4ce0-b8d3-57748123c644", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Spent March - May", + }, + ], + children: [ + { + id: "256828ef-82d6-4750-8433-423c786b6602", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Kevin: 35k out of 50k", + }, + ], + }, + { + id: "75c50e05-8150-4152-a082-f2fe719b7e63", + type: "bulletListItem", + content: [ + { + type: "text", + text: "BlockNote: 22k out of 50k", + }, + ], + }, + { + id: "d839357b-ab6a-4765-8040-5c72052fc574", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Total: 57k out of 100k (60%)", + }, + ], + }, + ], + }, + { + id: "7990fb3e-7031-482b-bd6b-c7e01bad3cb7", + type: "paragraph", + }, + { + id: "2d0b7eea-2ce0-45d3-9d6c-9990cc043a79", + type: "paragraph", + content: [ + { + type: "text", + text: "Status:", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " at risk 🟠", + }, + ], + }, + { + id: "c79a9782-2dd9-40cf-8fd0-d2d8b6e3dab4", + type: "paragraph", + content: [ + { + type: "text", + text: "+ there's still 40% of budget remaining", + }, + ], + }, + { + id: "c7d7bac7-d9e3-4170-be56-45c8053e9a37", + type: "paragraph", + content: [ + { + type: "text", + text: "- current roadblock (schema compatibility) is taking more time / resources", + }, + ], + }, + { + id: "ecd97b07-682f-40d5-a69a-564117671b96", + type: "paragraph", + content: [ + { + type: "text", + text: "- without a working demo we / client has not been able to start the user-testing phase yet, during which unknown issues could pop up.", + }, + ], + }, + { + id: "74a36180-92f0-4c7f-b079-24486d765f9f", + type: "paragraph", + content: [ + { + type: "text", + text: "+- Besides the schema compatibility roadblock, most of the identified work-items relate to Suggestions. We can re-scope to diffing / attributed versions and stay close to budget, after which we can revisit suggestions", + }, + ], + }, + { + id: "53aac82d-99ce-498e-8bc1-b932fe52ff1e", + type: "paragraph", + }, + { + id: "c6c883d5-8174-4cf6-8a29-f4d2f1c81845", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Timeline overview", + }, + ], + }, + { + id: "3d32d32a-ab6a-4d3d-9de0-0d831553ce12", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Original planning aimed for a beta version of suggestions and versioning in BlockNote by June 1st.", + }, + ], + }, + { + id: "a91a9df4-a5bf-4429-8c1d-e87af0f588b0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Status", + styles: { + bold: true, + }, + }, + { + type: "text", + text: ": missed target 🔴 ", + }, + ], + }, + { + id: "b3558b5b-a480-4a01-a279-69d7df6978b2", + type: "paragraph", + }, + { + id: "3a4d7e08-998c-48e8-bae1-664e8aaf9068", + type: "paragraph", + content: [ + { + type: "text", + text: "TODO: what's new timeline?", + }, + ], + }, + { + id: "504bdf5b-5c9c-4435-bd08-d52333d68a7e", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Schema compatibility", + }, + ], + }, + { + id: "1bf06b34-31d2-4946-8452-fe566f82ba58", + type: "paragraph", + content: [ + { + type: "text", + text: 'The main roadblock we\'re facing at this moment is the current approach to showing "diffs" (critical for both versioning and suggestions) in y-prosemirror developed so-far is incompatible with certain features of Prosemirror: complex schemas. ', + }, + ], + }, + { + id: "cf5fadc8-e2be-45fc-b03f-376533c12af7", + type: "paragraph", + content: [ + { + type: "text", + text: "BlockNote uses a relatively advanced schema to represent nested blocks (child blocks) and thus, we're running into issues setting up a BlockNote demo that goes beyond the basics.", + }, + ], + }, + { + id: "5bd6bff7-42b7-4dce-a6b3-4a67e4449e68", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "Technical explanation", + }, + ], + }, + { + id: "bc9d6844-fff9-4af8-a4cb-53f42763a12c", + type: "paragraph", + content: [ + { + type: "text", + text: "When a user changes a paragraph to a heading, y-prosemirror wants to change the Prosemirror state to the following:", + }, + ], + }, + { + id: "8260bb80-3022-421c-b1fe-050ba69f7234", + type: "paragraph", + }, + { + id: "bbff29b1-4261-4980-bf9a-0b2a29f43317", + type: "codeBlock", + props: { + language: "javascript", + }, + content: [ + { + type: "text", + text: "\nText\nText\n", + }, + ], + }, + { + id: "ba0e68ad-93ca-4e15-8848-01d9fbbcf521", + type: "paragraph", + }, + { + id: "79eed386-42a1-4040-86f6-c97b1c0f2310", + type: "paragraph", + content: [ + { + type: "text", + text: "However, this is not allowed in the BlockNote Prosemirror schema, because ", + }, + { + type: "text", + text: "blockcontainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " can only contain ", + }, + { + type: "text", + text: "blockContent blockgroup?", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (paragraph and heading are blockContent, blockgroup is optional in case there are child blocks). I.e.: a ", + }, + { + type: "text", + text: "BlockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " is allowed to only contain a single node like heading / paragraph.", + }, + ], + }, + { + id: "b1111058-0cac-4256-b575-9c986d8b8745", + type: "paragraph", + }, + { + id: "ac2d4c00-0f0e-4593-b603-8f21f969186a", + type: "paragraph", + content: [ + { + type: "text", + text: "The past +-2 weeks we've explored several ways to work around these issues (see ", + }, + { + type: "link", + href: "https://docs.blocknotejs.mosacloud.eu/docs/d4846e43-a647-42ba-ab14-b9f6031437c3/", + content: [ + { + type: "text", + text: "doc", + styles: {}, + }, + ], + }, + { + type: "text", + text: "). Broadly, remedies come down to:", + }, + ], + }, + { + id: "4a7e88d5-e945-421e-bb9b-0901856aca75", + type: "paragraph", + }, + { + id: "aa70b19a-d0b8-44c1-ac1f-a1049a057227", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "A: Change architecture of BlockNote", + }, + ], + }, + { + id: "82e90c8a-d18b-4847-a17a-46d7abc7b78a", + type: "paragraph", + content: [ + { + type: "text", + text: 'Change BlockNote in such a way that we relax the schema so "diffing nodes" (', + }, + { + type: "text", + text: "heading old", + styles: { + code: true, + }, + }, + { + type: "text", + text: " in the example) are allowed everywhere in the document. For example, we could:", + }, + ], + }, + { + id: "d7009d83-7fd6-4611-92ea-50f8141051c8", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Allow special "diffing nodes" within blockContainer', + }, + ], + }, + { + id: "3fddd740-1615-4fd9-bf27-a044ce7dc394", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Flatten the BlockNote PM schema as much as possible. For example, instead of using a tree-based structure to represent children / nesting, keep blocks in a flat array and use an ", + }, + { + type: "text", + text: "indentation", + styles: { + code: true, + }, + }, + { + type: "text", + text: " for nesting", + }, + ], + }, + { + id: "a468194d-5c50-48c7-a6c4-c9ca3d9c2b66", + type: "paragraph", + }, + { + id: "a5b2253a-5500-4739-87b2-0a00ac60d6c4", + type: "paragraph", + content: [ + { + type: "text", + text: "Pro:", + }, + ], + }, + { + id: "e46fae75-2767-4a37-a74e-4a4ba8ab3ac0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "We could expand the refactor to have some additional benefits:", + }, + ], + children: [ + { + id: "5a1636a4-ee19-4345-85d1-bade8c4130e5", + type: "bulletListItem", + content: [ + { + type: "text", + text: "better conflict-resolution for nesting / unnesting", + }, + ], + }, + { + id: "e4270ab2-da4b-4900-b5af-6374b7c059a1", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Indent/dedent would show cleaner in diffs", + }, + ], + }, + { + id: "f12ab91e-1bec-4ca3-8ec1-bbe16d54ca8a", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'the ability to do word / google docs style multi-tab indentation (instead of Notion-style "child" structure)', + }, + ], + }, + ], + }, + { + id: "184cb770-eccc-40aa-b29a-48a2d555b0ee", + type: "paragraph", + }, + { + id: "d3c432c9-0869-45a0-8a58-a18dc7322744", + type: "paragraph", + content: [ + { + type: "text", + text: "Con:", + }, + ], + }, + { + id: "e5fb5ec6-a5a7-4b8e-b3ff-2e01804c80ce", + type: "bulletListItem", + content: [ + { + type: "text", + text: "While feasible, this would affect almost all parts of the code base that interact with Prosemirror nodes, and would likely be a multi-week refactor (rough estimate 4 weeks).", + }, + ], + }, + { + id: "f9221164-b3d7-45e7-ae28-039e4cda44cb", + type: "paragraph", + }, + { + id: "9b328ccb-a58c-4804-a13c-3cdd615235cd", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "B: Change architecture of binding", + }, + ], + }, + { + id: "8c35ab69-b952-4715-93de-e0b48cba1690", + type: "paragraph", + content: [ + { + type: "link", + href: "https://blocknote-git-y-prosemirror-decorations-typecell.vercel.app/collaboration/yhub", + content: [ + { + type: "text", + text: "POC Demo", + styles: {}, + }, + ], + }, + ], + }, + { + id: "32da9f69-df08-40f7-bc0f-6304cef85a98", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/yjs/y-prosemirror/pull/264", + content: [ + { + type: "text", + text: "POC PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "0a474fef-8e96-4913-8f08-f26bb90e7dbd", + type: "paragraph", + content: [ + { + type: "text", + text: "Instead of having y-prosemirror interleave diffing information directly in the Prosemirror document state, information about diffs would be emitted as ", + }, + { + type: "text", + text: "metadata", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " separately. The editor (BlockNote) will then be responsible for rendering the diffs, likely using Prosemirror decorations.", + }, + ], + }, + { + id: "2625a3f7-122b-46c8-bed7-703707630275", + type: "paragraph", + }, + { + id: "95701bcc-72b4-4111-9b43-363603bd51da", + type: "paragraph", + content: [ + { + type: "text", + text: "This is a major architectural shift from how y-prosemirror currently works. ", + styles: { + bold: true, + }, + }, + { + type: "text", + text: "(min 2 weeks of work to make it work for suggestions, +- 1-3 days to make it work for static diffs)", + }, + ], + }, + { + id: "7e452494-ec6a-42dd-b6c2-3c4d659572fc", + type: "paragraph", + }, + { + id: "614dd3ae-6a51-4694-bcc7-7f616d19e0c1", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "4487ca50-1ccb-4883-b30b-07e8172c8a5d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Only solution that decouples the ", + styles: { + bold: true, + }, + }, + { + type: "text", + text: "rendering ", + styles: { + bold: true, + italic: true, + }, + }, + { + type: "text", + text: "of diffs completely from the document:", + styles: { + bold: true, + }, + }, + ], + children: [ + { + id: "cd742d02-63f2-48a3-a8f1-dd87aab04d0d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Consumers don't need to change schema; just works for everyone", + }, + ], + }, + { + id: "c10f33ba-52ae-4597-a3a1-e739e45c7b10", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Lets the editor control completely ", + }, + { + type: "text", + text: "how diffs are rendered", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " instead of being restricted to how the data layer (y-prosemirror) determines the diff. E.g.: you could even do side-by-side diffs, etc", + }, + ], + }, + { + id: "20356161-4ba0-480c-b9ea-f9e014ac304e", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Editor doesn't need to change its schema", + }, + ], + }, + { + id: "3106320b-70ba-4ae3-9883-bddce89572fc", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Editor (and prosemirror plugins) ", + }, + { + type: "text", + text: "don't need to account for suggestions (duplicate nodes) appearing", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " in the document state, because they're not part of the document anymore. ", + }, + ], + children: [ + { + id: "b736958c-cf7b-45e5-a51b-2e7f851819db", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Probably least work to make plugins prosemirror-tables compatible compared to other solutions", + }, + ], + }, + { + id: "7f83f148-166b-4c5a-90c9-79ee11fc22ae", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'BlockNote example: all other solutions need to rework the API surface, because there can now be a "deleted block" and an "inserted block" with the same id in the document. Requires work to make should APIs like ', + }, + { + type: "text", + text: "editor.getBlock(id)", + styles: { + code: true, + }, + }, + { + type: "text", + text: " and call sites handle this?", + }, + ], + }, + ], + }, + ], + }, + { + id: "48c0afec-90b3-4cd1-a75e-b188eefc9612", + type: "paragraph", + }, + { + id: "f627747c-b25d-4918-8fc1-b3fcb0ad9d98", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "66368e55-a4d1-4618-9f53-4dc0829bce5c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Deleted content is not a first class citizen of the editor anymore, but sits outside of it. This has some consequences. Without significant extra effort, with this approach we:", + }, + ], + children: [ + { + id: "fd7bc888-fbbf-49de-94c9-cdc52ea0f31c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Can't edit deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0bde15ed-8569-4149-a769-ec5bced4d956", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No cursors in deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "f8646c47-b339-4746-a93b-855071dfa16f", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Not possible to comment on deleted content (you can still comment on the "suggestion to delete", but not on comments on a part of the deleted area)', + styles: { + bold: true, + }, + }, + ], + }, + { + id: "ddbff30f-13b6-4556-af75-b1e72bd6ed22", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Some tricks needed to render a cursor on both sides of deleted content", + }, + ], + }, + ], + }, + { + id: "678a0e9b-3db7-434a-a395-30a4d102ed1c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "More work on the consumer (editor) to render content", + }, + ], + }, + { + id: "d7d7f768-5619-496a-9845-7b8ef49b75f1", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C: Use current architecture, but control where diffs are rendered", + }, + ], + }, + { + id: "151c0cc8-2782-4b4f-a995-92c80692aadb", + type: "paragraph", + content: [ + { + type: "text", + text: "Before choosing option A or B, we can explore alternatives that use the current architecture of both y-prosemirror and BlockNote.", + }, + ], + }, + { + id: "a97ea356-1d8f-4881-aae7-3ae39a76d54d", + type: "paragraph", + }, + { + id: "d747a50e-577f-4fef-8986-34087444f091", + type: "heading", + props: { + level: 4, + }, + }, + { + id: "574bf899-c9b5-4815-a097-d6813878e9be", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/YousefED/y-prosemirror/pull/2", + content: [ + { + type: "text", + text: "POC PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "71790421-d87a-4fda-8750-5918ecb30de9", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "c234ca5a-8d40-43a0-b609-0e1761a00695", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'No editor schema change needed: duplicate nodes will only appear at the "block boundary"', + }, + ], + }, + { + id: "1c597102-4c8a-42fb-8161-547ce78b3327", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Can improve "conflict resolution" of some other operations (e.g.: multiple users create a child block)', + }, + ], + }, + { + id: "09c694da-9118-43a6-8ca6-efc99f72d18c", + type: "paragraph", + }, + { + id: "c730ff68-41bd-4817-b5b7-d61cdb10b291", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "195f16d9-5d4f-48ac-89a2-9a02c457b28f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to be very delicate about how to allow this functionality (how to expose it from y-prosemirror)", + }, + ], + children: [ + { + id: "77124308-3a25-4204-a22c-bd8d64f96b31", + type: "bulletListItem", + content: [ + { + type: "text", + text: "For example: only allow transforming certain nodes in a safe manner: e.g. ", + }, + { + type: "text", + text: "", + styles: { + code: true, + }, + }, + { + type: "text", + text: " ↦ ", + }, + { + type: "text", + text: '<_block type="paragraph"', + styles: { + code: true, + }, + }, + { + type: "text", + text: " .", + }, + ], + }, + ], + }, + { + id: "927b24ee-8c08-4262-95a1-315a52cadf47", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Requires data migration", + }, + ], + }, + { + id: "87f6f274-6f98-44a2-b96f-b8ca391a59ff", + type: "bulletListItem", + content: [ + { + type: "text", + text: "The Yjs storage format", + }, + ], + }, + { + id: "5e72c1e9-1cfe-47cb-8db1-d155a5284e4e", + type: "paragraph", + }, + { + id: "da6c5c36-828e-4f87-bdd1-4197b143294d", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C2: custom diffing boundary", + }, + ], + }, + { + id: "06d99d26-28a2-42e7-9e08-d87a20e8586a", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/yjs/y-prosemirror/pull/267", + content: [ + { + type: "text", + text: "POC PR y-prosemirror", + styles: {}, + }, + ], + }, + { + type: "text", + text: " / BlockNote ", + }, + { + type: "link", + href: "https://github.com/TypeCellOS/BlockNote/pull/2849", + content: [ + { + type: "text", + text: "PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "b46f672b-f6a8-44e1-a9f6-c4a3652ed3a6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://blocknote-git-y-prosemirror-tests-matchnodes-typecell.vercel.app/collaboration/yhub", + content: [ + { + type: "text", + text: "POC Demo", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4161db5c-05d3-4451-a8b3-08977cd6f6a3", + type: "paragraph", + }, + { + id: "5c3e6b25-8b6b-4d58-9fa5-7025c2d1e916", + type: "paragraph", + content: [ + { + type: "text", + text: "This POC lets the diff decide ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "modify-in-place vs. replace", + styles: { + italic: true, + }, + }, + { + type: "text", + text: " via a caller-supplied predicate, so the boundary can be raised to a whole node. In this way, the diff produces two sibling ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "blockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: "s (allowed in schema) instead of two block-contents in one ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "blockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (not allowed in schema)", + }, + { + type: "text", + text: ".", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + ], + }, + { + id: "c3a4efd8-971f-4cb6-be5e-43b889ccb768", + type: "paragraph", + }, + { + id: "0d5d4caf-38bb-48e1-a5bc-803737023b61", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "3144fbf6-e488-4e90-a1e8-99a8bfd33ba8", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Relatively simple change", + }, + ], + }, + { + id: "14e399c6-a3ee-4781-a2ac-f4117d69b730", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'No editor schema change needed: duplicate nodes will only appear at the "block boundary"', + }, + ], + }, + { + id: "769a9e15-1209-4bf7-adc7-dc8faf4665ec", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No data migration needed", + }, + ], + }, + { + id: "8018af78-6997-4767-a29d-428d3d97af0c", + type: "paragraph", + }, + { + id: "9bc9cb75-33a1-44ed-a074-fd0e6016ccae", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "63d5dbf8-56b9-422b-84c7-2b809ca39531", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Changing a block type (e.g. heading -> paragraph) will create a new blockcontainer node. This has some downsides:", + }, + ], + children: [ + { + id: "44acffd1-f97f-4330-81d8-55a0ae77a9dc", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'attribution: all nested children will be "copied", and attributed to the user who made the change', + }, + ], + }, + { + id: "5204487f-f071-47f5-a4f5-feff4293c92c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "diffing: the entire block will be shown as modified, including child blocks, when the parent block type was changed", + }, + ], + }, + { + id: "cf30c75b-6e9e-4318-aad3-38d2696dcebd", + type: "bulletListItem", + content: [ + { + type: "text", + text: "conflicts: simultaneous block-type changes and text / children edits won't merge nicely (will be LWW)", + }, + ], + }, + ], + }, + { + id: "0550d822-274c-4071-83e3-93d71d30de3c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TBD: We might not be able to visualize it when two users both add a nested block, or, similar to the above, this would be a new blockcontainer node with same downsides of attribution / diffing / conflicts (but for adding / removing the first child block instead of for changing the block type)", + }, + ], + }, + { + id: "e9dd7478-b824-4c1f-a0d9-12fb5123804f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TBD: works with older docs?", + }, + ], + }, + { + id: "95361861-86b7-41b1-8726-76f96e849613", + type: "paragraph", + }, + { + id: "84c9c4ce-f116-4bc6-b5df-119e09711db8", + type: "paragraph", + content: [ + { + type: "text", + text: "We're still investigating this solution", + }, + ], + }, + { + id: "32d4f752-75fc-45b6-ad10-a4bc3bc47f65", + type: "paragraph", + }, + { + id: "50f9a169-419e-4bd1-af3a-2af89350524e", + type: "divider", + }, + { + id: "2e2cea10-5373-4ced-bfc0-d0eba8c4f59d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Open tasks", + }, + ], + }, + { + id: "62986f28-3a36-4702-83f8-194a6a805dc0", + type: "paragraph", + content: [ + { + type: "text", + text: "The currently scoped remaining work has been categorized in 5 phases:", + }, + ], + }, + { + id: "bd772ad8-b12d-42d2-8082-be58366cbc3b", + type: "paragraph", + }, + { + id: "de0e3f48-8b39-44f4-8766-c8344dc97d79", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "1: Demo readiness", + }, + ], + }, + { + id: "cf5b7f55-53fe-4847-9ff9-2adff6beeee6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Demo+readiness%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4cd9804b-f320-4cfe-83a2-a25c46066104", + type: "paragraph", + }, + { + id: "6ca5e395-6654-4b05-b616-ef3bcdf96239", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Get the current work to a demoable and testable state", + }, + ], + }, + { + id: "c009551c-2f98-4ea8-8654-404e6b7444ac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "66971ae0-91f7-4c42-9c23-2e679527ab45", + type: "bulletListItem", + content: [ + { + type: "text", + text: "schema compatibility", + }, + ], + }, + { + id: "d328f8ed-1a83-489c-bca4-79bdf044fbac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Add support for Table diffs to BlockNote and y-prosemirror", + }, + ], + children: [ + { + id: "f2d81783-ccb4-4d65-b94c-11c168899607", + type: "bulletListItem", + content: [ + { + type: "text", + text: "This has some unknowns and potentially needs a number of changes to ", + }, + { + type: "text", + text: "prosemirror-tables", + styles: { + code: true, + }, + }, + ], + }, + ], + }, + ], + }, + { + id: "c9ca8f7f-3a08-46cb-90bc-dc5696d7f260", + type: "paragraph", + }, + { + id: "e41f3d25-e219-4b3c-9f4b-faf64ba214d4", + type: "paragraph", + content: [ + { + type: "text", + text: "Estimate: depends on schema next step", + }, + ], + }, + { + id: "0b33469a-e047-4e86-846f-ee820583ce82", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "2: Stability", + }, + ], + }, + { + id: "58b960dd-d5f8-4af3-a8a7-37ffaebd3611", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Stability+%28diffs+%2F+versions%29%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "040949d3-65c5-43e6-ae57-a8f27c5c9f73", + type: "paragraph", + }, + { + id: "7937911d-a79c-4774-af90-67b342c7fa23", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Fix known issues in the current y-prosemirror binding", + }, + ], + }, + { + id: "0d7025db-3eb1-4bfc-b693-e885b4d60390", + type: "bulletListItem", + content: [ + { + type: "text", + text: "y-prosemirror at level that is comfortable to release as new major version", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0981d2e1-9ab3-48f8-b1a1-4365a548b2b8", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TODO Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "18b5622c-76e3-4e41-9659-820801967147", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Potential new items after testing demo", + }, + ], + }, + ], + }, + { + id: "e67ccfc3-10c1-4fb1-80a8-f0ffaf03ff91", + type: "paragraph", + }, + { + id: "625026e4-198e-4ad7-ab64-f884e82aaf9a", + type: "paragraph", + content: [ + { + type: "text", + text: "Initial estimate Kevin: 5-8 days + ??? for unknowns", + }, + ], + }, + { + id: "19c4f7cb-0c2e-4fe7-b25c-af9f31fb0aba", + type: "paragraph", + }, + { + id: "eea91bef-61f2-4ac8-9d2c-559fdc528a30", + type: "paragraph", + content: [ + { + type: "text", + text: "2 XS", + }, + ], + }, + { + id: "5e3740c6-cee0-4e53-a6c0-8c55a7864ce5", + type: "paragraph", + content: [ + { + type: "text", + text: "2 S", + }, + ], + }, + { + id: "1cc8ee7b-0e9d-43bd-9f10-68e5e2295b73", + type: "paragraph", + content: [ + { + type: "text", + text: "3 M", + }, + ], + }, + { + id: "8b19fecc-ceed-4139-99dd-5bac14990fd4", + type: "paragraph", + content: [ + { + type: "text", + text: "1 L", + }, + ], + }, + { + id: "2e1b9234-38a5-4e07-98b3-1c2196054d88", + type: "paragraph", + }, + { + id: "5b16f492-2b25-400c-987b-97b8a5eb4c90", + type: "paragraph", + content: [ + { + type: "text", + text: "Counted estimate: 2+(3-6)+(2-5) = 6-13 days + ??? for unknowns", + }, + ], + }, + { + id: "f2eb1b59-3909-4a6c-af47-b31662cabeae", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "3: BlockNote level features", + }, + ], + }, + { + id: "49904179-8e10-4770-9587-524966c4581c", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22BlockNote+level+features%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "a959c626-b4bc-4efa-af6e-b15aedb177b6", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Implement history panel", + }, + ], + }, + { + id: "14138376-912f-428f-ab74-643652c6bb62", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Update BlockNote APIs and documentation, make existing BlockNote APIs compatible with "diff views"', + }, + ], + }, + { + id: "69af46e1-d7c2-436f-8bae-dae836d3ae96", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "6787c2bc-28d5-4cf6-9bb0-1d05aceefddb", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "37fa84cf-570a-411a-9e96-c9e3bc9113d0", + type: "paragraph", + }, + { + id: "a450596d-8901-4712-8f3c-d4425a53d72b", + type: "paragraph", + content: [ + { + type: "text", + text: "1 L", + }, + ], + }, + { + id: "540c68ec-944b-45a0-9f79-4e1591b98af1", + type: "paragraph", + content: [ + { + type: "text", + text: "2 M", + }, + ], + }, + { + id: "f89a92e8-6742-416f-af07-b65c099b7718", + type: "paragraph", + }, + { + id: "859ed9e8-be8d-4582-ac11-f7195a5f1f7c", + type: "paragraph", + content: [ + { + type: "text", + text: "= 4-9 days", + }, + ], + }, + { + id: "2cb75462-1df9-4f94-bf7b-4ebb3de96fbb", + type: "paragraph", + }, + { + id: "81363d76-5daa-44f1-ac79-61b967f193db", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "4: Rollout", + }, + ], + }, + { + id: "ae6ac180-2492-41c6-9abb-2ae4dc00d4df", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Release+%2F+rollout%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "c6c65714-7d1b-43fc-8d23-5f6a452b4f18", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Migration guide", + }, + ], + }, + { + id: "361cbb94-ec74-482a-8c08-e2b938183064", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Stable release of y-prosemirror + yjs + lib0", + }, + ], + children: [ + { + id: "d010e74c-d72f-4d11-9d96-fac784b9ec6a", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Planned for end of August", + }, + ], + }, + ], + }, + { + id: "2eed8480-da1e-4f64-90ba-3ccf6a9df08f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Release of BlockNote with (optional) new Yjs / y-prosemirror compatibility", + }, + ], + }, + { + id: "19b84b6a-5c1e-4637-917f-57282cff6612", + type: "paragraph", + }, + { + id: "e350b6ae-c8ff-4968-9149-419b08328923", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "5f7423fe-81d4-4a5b-8c1e-b9797308ec2b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "b5eab023-1223-4fd9-817f-3dbca47c5e7d", + type: "paragraph", + }, + { + id: "eade368f-5d90-49d6-b012-42e873d2dddf", + type: "paragraph", + content: [ + { + type: "text", + text: "3 M", + }, + ], + }, + { + id: "569e9d88-2901-41df-acb0-f1b631012df3", + type: "paragraph", + content: [ + { + type: "text", + text: "1 S", + }, + ], + }, + { + id: "1c415b65-68b1-4ab3-adf4-7e0c903a9232", + type: "paragraph", + }, + { + id: "630aaf9f-de91-4643-a2af-8e47f1c67ef2", + type: "paragraph", + content: [ + { + type: "text", + text: "= 3.5 - 6.5 days", + }, + ], + }, + { + id: "ab044a82-f38c-459a-99c7-342bb176e556", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "5: Suggestions", + }, + ], + }, + { + id: "0e9ae6b9-85b1-4164-a137-e14323edb349", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Suggestions+%28track+changes%29%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "2b12f097-fe31-43dd-97db-6bea1e33dfa2", + type: "paragraph", + }, + { + id: "e8cca260-bc7e-4a2c-834d-89a7d337e336", + type: "paragraph", + content: [ + { + type: "text", + text: "Specific features related to suggestions / track changes.", + }, + ], + }, + { + id: "116548de-8362-432b-af2c-af2702e16d5e", + type: "paragraph", + }, + { + id: "6476312d-aa12-4e5b-a093-bd36b90fddca", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "357f34c3-599e-4068-a196-83cc6930f2f0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "delete suggestions", + }, + ], + }, + ], + }, + { + id: "3fe5c25b-87a8-4b97-bb3f-675bce4a7848", + type: "paragraph", + }, + { + id: "f2e94dbf-07ea-4f37-9a3c-438b0431618d", + type: "paragraph", + content: [ + { + type: "text", + text: "1 XL", + }, + ], + }, + { + id: "d13e3a8e-8239-40fc-ba89-7a9c57af2c88", + type: "paragraph", + content: [ + { + type: "text", + text: "3 L", + }, + ], + }, + { + id: "2822cf62-665d-4d82-bfe3-3e6d9b87419f", + type: "paragraph", + content: [ + { + type: "text", + text: "4 M", + }, + ], + }, + { + id: "596a8524-457a-41e8-b715-042adc217db2", + type: "paragraph", + content: [ + { + type: "text", + text: "2 S", + }, + ], + }, + { + id: "c24c882a-b16a-41ea-b7dd-20c057777353", + type: "paragraph", + }, + { + id: "057da6a6-7b99-478e-8629-14efcad4028d", + type: "paragraph", + content: [ + { + type: "text", + text: "= (6-15)+(4-8)+1 = 11-24 days", + }, + ], + }, + { + id: "c7e9c438-e13c-4a3d-811e-f057c53dc3cf", + type: "paragraph", + }, + { + id: "7241a164-f239-4720-9e02-918dcaab4bc0", + type: "paragraph", + content: [ + { + type: "text", + text: "24-50 days including suggestions", + }, + ], + }, + { + id: "d28b8635-26f0-4d76-a7fb-109ccf43c887", + type: "paragraph", + }, + { + id: "0a2e2695-c83b-44cb-9143-180a165edc04", + type: "heading", + props: { + level: 4, + }, + content: [ + { + type: "text", + text: "paragraph", + }, + ], + children: [ + { + id: "cb42c39b-0287-4e24-88f1-6af366bf26df", + type: "paragraph", + content: [ + { + type: "text", + text: "nested", + }, + ], + }, + { + id: "45af11e1-3e8f-45bf-a7a1-aad495ac012c", + type: "paragraph", + content: [ + { + type: "text", + text: "nested 2", + }, + ], + }, + { + id: "5fbe5416-5211-4557-9fe6-2423f586ea3a", + type: "paragraph", + content: [ + { + type: "text", + text: "nested 3", + }, + ], + }, + ], + }, + { + id: "543ee1ab-e938-48f7-adf6-adf12fcf2b61", + type: "paragraph", + }, + { + id: "29161299-98be-4da7-8193-54bfa421b348", + type: "paragraph", + }, + { + id: "189cfad8-9dd5-4a8a-bbdc-2b9e7475276f", + type: "paragraph", + }, + { + id: "bfc0b6ed-5d4b-4025-a633-141dfb350882", + type: "paragraph", + }, + { + id: "fd82942c-bdd4-4f22-8e97-e9ab38ab4566", + type: "paragraph", + }, + { + id: "0ab346e1-d0b5-46d5-bff5-0e4ff2fcdeb3", + type: "paragraph", + content: [ + { + type: "text", + text: "august", + }, + ], + }, + { + id: "17b42588-eecf-4d3e-9979-56230a0a1191", + type: "paragraph", + }, + { + id: "6fe30bb4-b5cd-4237-b366-46b821875798", + type: "paragraph", + content: [ + { + type: "text", + text: "end of september", + }, + ], + }, + { + id: "32d44d41-fc24-4e57-8869-4d06bc6f5d98", + type: "paragraph", + content: [ + { + type: "text", + text: "end of december", + }, + ], + }, + { + id: "74154187-c2a8-4d68-92e1-a08dd22ae2d7", + type: "paragraph", + }, + { + id: "9d145c8b-8c95-481d-8e29-6659f7bcb80c", + type: "paragraph", + }, + ], + v5: [ + { + id: "initialBlockId", + type: "paragraph", + }, + { + id: "62818104-164b-4473-9760-25ffbc55937c", + type: "paragraph", + content: [ + { + type: "text", + text: "(For looking back what has been completed, there are the status updates)", + }, + ], + }, + { + id: "193cbfcd-e467-4377-83b6-2641d042e88d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Budget overview", + }, + ], + }, + { + id: "30c43793-835c-4ce0-b8d3-57748123c644", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Spent March - May", + }, + ], + children: [ + { + id: "256828ef-82d6-4750-8433-423c786b6602", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Kevin: 41k out of 50k", + }, + ], + }, + { + id: "75c50e05-8150-4152-a082-f2fe719b7e63", + type: "bulletListItem", + content: [ + { + type: "text", + text: "BlockNote: 37k out of 50k", + }, + ], + }, + ], + }, + { + id: "d839357b-ab6a-4765-8040-5c72052fc574", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Total: 78k out of 100k (80%)", + }, + ], + }, + { + id: "7990fb3e-7031-482b-bd6b-c7e01bad3cb7", + type: "paragraph", + }, + { + id: "2d0b7eea-2ce0-45d3-9d6c-9990cc043a79", + type: "paragraph", + content: [ + { + type: "text", + text: "Status:", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " at risk 🟠", + }, + ], + }, + { + id: "c79a9782-2dd9-40cf-8fd0-d2d8b6e3dab4", + type: "paragraph", + content: [ + { + type: "text", + text: "+ there's still 40% of budget remaining (", + }, + { + type: "text", + text: "Note", + styles: { + bold: true, + }, + }, + { + type: "text", + text: ": June 1st)", + }, + ], + }, + { + id: "c7d7bac7-d9e3-4170-be56-45c8053e9a37", + type: "paragraph", + content: [ + { + type: "text", + text: "- current roadblock (schema compatibility) is taking more time / resources", + }, + ], + }, + { + id: "ecd97b07-682f-40d5-a69a-564117671b96", + type: "paragraph", + content: [ + { + type: "text", + text: "- without a working demo we / client has not been able to start the user-testing phase yet, during which unknown issues could pop up.", + }, + ], + }, + { + id: "74a36180-92f0-4c7f-b079-24486d765f9f", + type: "paragraph", + content: [ + { + type: "text", + text: "+- Besides the schema compatibility roadblock, most of the identified work-items relate to Suggestions. We can re-scope to diffing / attributed versions and stay close to budget, after which we can revisit suggestions", + }, + ], + children: [ + { + id: "74a40e6f-de52-4e2f-82aa-4811a6e7237b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "note: excludes possible extra work for yhub migration", + }, + ], + }, + ], + }, + { + id: "53aac82d-99ce-498e-8bc1-b932fe52ff1e", + type: "paragraph", + }, + { + id: "c6c883d5-8174-4cf6-8a29-f4d2f1c81845", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Timeline overview", + }, + ], + }, + { + id: "3d32d32a-ab6a-4d3d-9de0-0d831553ce12", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Original planning aimed for a beta version of suggestions and versioning in BlockNote by June 1st.", + }, + ], + }, + { + id: "a91a9df4-a5bf-4429-8c1d-e87af0f588b0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Status", + styles: { + bold: true, + }, + }, + { + type: "text", + text: ": missed target 🔴 ", + }, + ], + }, + { + id: "b3558b5b-a480-4a01-a279-69d7df6978b2", + type: "paragraph", + }, + { + id: "ec1d43d3-f397-4288-a957-7b20c06d08a6", + type: "paragraph", + }, + { + id: "19df856a-c621-4d09-86df-b59aa62efc0c", + type: "divider", + }, + { + id: "504bdf5b-5c9c-4435-bd08-d52333d68a7e", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Schema compatibility", + }, + ], + }, + { + id: "1bf06b34-31d2-4946-8452-fe566f82ba58", + type: "paragraph", + content: [ + { + type: "text", + text: 'The main roadblock we\'re facing at this moment is the current approach to showing "diffs" (critical for both versioning and suggestions) in y-prosemirror developed so-far is incompatible with certain features of Prosemirror: complex schemas. ', + }, + ], + }, + { + id: "cf5fadc8-e2be-45fc-b03f-376533c12af7", + type: "paragraph", + content: [ + { + type: "text", + text: "BlockNote uses a relatively advanced schema to represent nested blocks (child blocks) and thus, we're running into issues setting up a BlockNote demo that goes beyond the basics.", + }, + ], + }, + { + id: "5bd6bff7-42b7-4dce-a6b3-4a67e4449e68", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "Technical explanation", + }, + ], + }, + { + id: "bc9d6844-fff9-4af8-a4cb-53f42763a12c", + type: "paragraph", + content: [ + { + type: "text", + text: "When a user changes a paragraph to a heading, y-prosemirror wants to change the Prosemirror state to the following:", + }, + ], + }, + { + id: "8260bb80-3022-421c-b1fe-050ba69f7234", + type: "paragraph", + }, + { + id: "bbff29b1-4261-4980-bf9a-0b2a29f43317", + type: "codeBlock", + props: { + language: "javascript", + }, + content: [ + { + type: "text", + text: "\nText\nText\n", + }, + ], + }, + { + id: "ba0e68ad-93ca-4e15-8848-01d9fbbcf521", + type: "paragraph", + }, + { + id: "79eed386-42a1-4040-86f6-c97b1c0f2310", + type: "paragraph", + content: [ + { + type: "text", + text: "However, this is not allowed in the BlockNote Prosemirror schema, because ", + }, + { + type: "text", + text: "blockcontainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " can only contain ", + }, + { + type: "text", + text: "blockContent blockgroup?", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (paragraph and heading are blockContent, blockgroup is optional in case there are child blocks). I.e.: a ", + }, + { + type: "text", + text: "BlockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " is allowed to only contain a single node like heading / paragraph.", + }, + ], + }, + { + id: "b1111058-0cac-4256-b575-9c986d8b8745", + type: "paragraph", + }, + { + id: "ac2d4c00-0f0e-4593-b603-8f21f969186a", + type: "paragraph", + content: [ + { + type: "text", + text: "The past +-2 weeks we've explored several ways to work around these issues (see ", + }, + { + type: "link", + href: "https://docs.blocknotejs.mosacloud.eu/docs/d4846e43-a647-42ba-ab14-b9f6031437c3/", + content: [ + { + type: "text", + text: "doc", + styles: {}, + }, + ], + }, + { + type: "text", + text: "). Broadly, remedies come down to one of 3 solutions:", + }, + ], + }, + { + id: "39e60437-a012-485c-9a00-7b544061de09", + type: "paragraph", + }, + { + id: "84f7f5db-e274-4396-92fd-87aa2fc9d28d", + type: "divider", + }, + { + id: "e1bf56d9-d64d-4bba-ada5-3d3a1a1334ac", + type: "image", + props: { + name: "image.png", + url: "https://docs.blocknotejs.mosacloud.eu/media/8819d7a2-fc6b-4f2d-99df-9848fdb5c105/attachments/d0bc8283-1ad7-468a-bfab-84dcb4704d63.png", + }, + }, + { + id: "3962b829-4de5-418b-a22b-b7937705c1db", + type: "paragraph", + }, + { + id: "aa70b19a-d0b8-44c1-ac1f-a1049a057227", + type: "divider", + }, + { + id: "5b3f3808-a3de-44ee-a5e1-2d610e21f423", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "A: Change architecture of BlockNote", + }, + ], + }, + { + id: "82e90c8a-d18b-4847-a17a-46d7abc7b78a", + type: "paragraph", + content: [ + { + type: "text", + text: 'Change BlockNote in such a way that we relax the schema so "diffing nodes" (', + }, + { + type: "text", + text: "heading old", + styles: { + code: true, + }, + }, + { + type: "text", + text: " in the example) are allowed everywhere in the document. For example, we could:", + }, + ], + }, + { + id: "d7009d83-7fd6-4611-92ea-50f8141051c8", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Allow special "diffing nodes" within blockContainer', + }, + ], + }, + { + id: "3fddd740-1615-4fd9-bf27-a044ce7dc394", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Flatten the BlockNote PM schema as much as possible. For example, instead of using a tree-based structure to represent children / nesting, keep blocks in a flat array and use an ", + }, + { + type: "text", + text: "indentation", + styles: { + code: true, + }, + }, + { + type: "text", + text: " for nesting", + }, + ], + }, + { + id: "a468194d-5c50-48c7-a6c4-c9ca3d9c2b66", + type: "paragraph", + }, + { + id: "a5b2253a-5500-4739-87b2-0a00ac60d6c4", + type: "paragraph", + content: [ + { + type: "text", + text: "Pro:", + }, + ], + }, + { + id: "e46fae75-2767-4a37-a74e-4a4ba8ab3ac0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "We could expand the refactor to have some additional benefits:", + }, + ], + children: [ + { + id: "5a1636a4-ee19-4345-85d1-bade8c4130e5", + type: "bulletListItem", + content: [ + { + type: "text", + text: "better conflict-resolution for nesting / unnesting", + }, + ], + }, + { + id: "e4270ab2-da4b-4900-b5af-6374b7c059a1", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Indent/dedent would show cleaner in diffs", + }, + ], + }, + { + id: "f12ab91e-1bec-4ca3-8ec1-bbe16d54ca8a", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'the ability to do word / google docs style multi-tab indentation (instead of Notion-style "child" structure)', + }, + ], + }, + ], + }, + { + id: "184cb770-eccc-40aa-b29a-48a2d555b0ee", + type: "paragraph", + }, + { + id: "d3c432c9-0869-45a0-8a58-a18dc7322744", + type: "paragraph", + content: [ + { + type: "text", + text: "Con:", + }, + ], + }, + { + id: "e5fb5ec6-a5a7-4b8e-b3ff-2e01804c80ce", + type: "bulletListItem", + content: [ + { + type: "text", + text: "While feasible, this would affect almost all parts of the code base that interact with Prosemirror nodes, and would likely be a multi-week refactor (rough estimate 4 weeks).", + }, + ], + }, + { + id: "6e4f7350-8f94-48b3-9818-30685caebd84", + type: "divider", + }, + { + id: "9b328ccb-a58c-4804-a13c-3cdd615235cd", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "B: Change architecture of y-prosemirror", + }, + ], + }, + { + id: "8c35ab69-b952-4715-93de-e0b48cba1690", + type: "paragraph", + content: [ + { + type: "link", + href: "https://blocknote-git-y-prosemirror-decorations-typecell.vercel.app/collaboration/yhub", + content: [ + { + type: "text", + text: "POC Demo", + styles: {}, + }, + ], + }, + ], + }, + { + id: "32da9f69-df08-40f7-bc0f-6304cef85a98", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/yjs/y-prosemirror/pull/264", + content: [ + { + type: "text", + text: "POC PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "0a474fef-8e96-4913-8f08-f26bb90e7dbd", + type: "paragraph", + content: [ + { + type: "text", + text: "Instead of having y-prosemirror interleave diffing information directly in the Prosemirror document state, information about diffs would be emitted as ", + }, + { + type: "text", + text: "metadata", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " separately. The editor (BlockNote) will then be responsible for rendering the diffs, likely using Prosemirror decorations.", + }, + ], + }, + { + id: "2625a3f7-122b-46c8-bed7-703707630275", + type: "paragraph", + }, + { + id: "95701bcc-72b4-4111-9b43-363603bd51da", + type: "paragraph", + content: [ + { + type: "text", + text: "This is a major architectural shift from how y-prosemirror currently works. ", + styles: { + bold: true, + }, + }, + { + type: "text", + text: "(min 2 weeks of work to make it work for suggestions, +- 1-3 days to make it work for static diffs)", + }, + ], + }, + { + id: "7e452494-ec6a-42dd-b6c2-3c4d659572fc", + type: "paragraph", + }, + { + id: "614dd3ae-6a51-4694-bcc7-7f616d19e0c1", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "4487ca50-1ccb-4883-b30b-07e8172c8a5d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Clean separation of concerns: only solution that decouples the ", + styles: { + bold: true, + }, + }, + { + type: "text", + text: "rendering ", + styles: { + bold: true, + italic: true, + }, + }, + { + type: "text", + text: "of diffs completely from the document:", + styles: { + bold: true, + }, + }, + ], + children: [ + { + id: "cd742d02-63f2-48a3-a8f1-dd87aab04d0d", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Consumers don't need to change schema; just works for everyone", + }, + ], + }, + { + id: "c10f33ba-52ae-4597-a3a1-e739e45c7b10", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Lets the editor control completely ", + }, + { + type: "text", + text: "how diffs are rendered", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " instead of being restricted to how the data layer (y-prosemirror) determines the diff. E.g.: you could even do side-by-side diffs, etc", + }, + ], + }, + { + id: "20356161-4ba0-480c-b9ea-f9e014ac304e", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Editor doesn't need to change its schema", + }, + ], + }, + { + id: "3106320b-70ba-4ae3-9883-bddce89572fc", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Editor (and prosemirror plugins) ", + }, + { + type: "text", + text: "don't need to account for suggestions (duplicate nodes) appearing", + styles: { + bold: true, + }, + }, + { + type: "text", + text: " in the document state, because they're not part of the document anymore. ", + }, + ], + children: [ + { + id: "b736958c-cf7b-45e5-a51b-2e7f851819db", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Probably least work to make plugins prosemirror-tables compatible compared to other solutions", + }, + ], + }, + { + id: "7f83f148-166b-4c5a-90c9-79ee11fc22ae", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'BlockNote example: all other solutions need to rework the API surface, because there can now be a "deleted block" and an "inserted block" with the same id in the document. Requires work to make should APIs like ', + }, + { + type: "text", + text: "editor.getBlock(id)", + styles: { + code: true, + }, + }, + { + type: "text", + text: " and call sites handle this?", + }, + ], + }, + ], + }, + ], + }, + { + id: "48c0afec-90b3-4cd1-a75e-b188eefc9612", + type: "paragraph", + }, + { + id: "f627747c-b25d-4918-8fc1-b3fcb0ad9d98", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "66368e55-a4d1-4618-9f53-4dc0829bce5c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Deleted content is not a first class citizen of the editor anymore, but sits outside of it. This has some consequences. Without significant extra effort, with this approach we:", + }, + ], + children: [ + { + id: "fd7bc888-fbbf-49de-94c9-cdc52ea0f31c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Can't edit deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0bde15ed-8569-4149-a769-ec5bced4d956", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No cursors in deleted content", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "f8646c47-b339-4746-a93b-855071dfa16f", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Not possible to comment on deleted content (you can still comment on the "suggestion to delete", but not on comments on a part of the deleted area)', + styles: { + bold: true, + }, + }, + ], + }, + { + id: "ddbff30f-13b6-4556-af75-b1e72bd6ed22", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Some tricks needed to render a cursor on both sides of deleted content", + }, + ], + }, + ], + }, + { + id: "678a0e9b-3db7-434a-a395-30a4d102ed1c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "More work on the consumer (editor) to render content", + }, + ], + }, + { + id: "bfd3b83c-c6b7-40a9-8d06-168bac12ab10", + type: "paragraph", + }, + { + id: "e4731f10-766f-4925-8f32-8f23b0f7f7dd", + type: "divider", + }, + { + id: "d7d7f768-5619-496a-9845-7b8ef49b75f1", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C: Use current architecture, but control where diffs are rendered", + }, + ], + }, + { + id: "151c0cc8-2782-4b4f-a995-92c80692aadb", + type: "paragraph", + content: [ + { + type: "text", + text: "Before choosing option A or B, we can explore alternatives that use the current architecture of both y-prosemirror and BlockNote.", + }, + ], + }, + { + id: "d747a50e-577f-4fef-8986-34087444f091", + type: "heading", + props: { + level: 3, + isToggleable: true, + }, + content: [ + { + type: "text", + text: "C1: yjs <-> ProseMirror custom transforms (can skip this one)", + }, + ], + children: [ + { + id: "ec7c1f2f-0de3-430f-826b-35d9a322c254", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/YousefED/y-prosemirror/pull/2", + content: [ + { + type: "text", + text: "POC PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "517b36b4-0c9b-47dd-8ca7-74f18ed20f50", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "95ffbe3a-dcc0-4e9e-92f9-0b754b041363", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'No editor schema change needed: duplicate nodes will only appear at the "block boundary"', + }, + ], + }, + { + id: "47396f50-cec2-40a3-b620-722bf33a8888", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Can improve "conflict resolution" of some other operations (e.g.: multiple users create a child block)', + }, + ], + }, + { + id: "bebced40-0054-4078-bc9d-b9ec4582dea5", + type: "paragraph", + }, + { + id: "b4fac941-5b23-441f-8c42-eeb166032340", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "90a28933-b35d-4151-a36d-e64568d67f91", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Need to be very delicate about how to allow this functionality (how to expose it from y-prosemirror)", + }, + ], + children: [ + { + id: "642572dc-b85c-46c1-948c-f06b7df2a2f3", + type: "bulletListItem", + content: [ + { + type: "text", + text: "For example: only allow transforming certain nodes in a safe manner: e.g. ", + }, + { + type: "text", + text: "", + styles: { + code: true, + }, + }, + { + type: "text", + text: " ↦ ", + }, + { + type: "text", + text: '<_block type="paragraph"', + styles: { + code: true, + }, + }, + { + type: "text", + text: " .", + }, + ], + }, + ], + }, + { + id: "e84951b8-bab2-4416-b614-ff98e543926c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Requires data migration", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "46aa6f60-3631-4455-afda-d74c2c3886a2", + type: "bulletListItem", + content: [ + { + type: "text", + text: "The Yjs storage format", + }, + ], + }, + ], + }, + { + id: "da6c5c36-828e-4f87-bdd1-4197b143294d", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "C2: custom diffing boundary", + }, + ], + }, + { + id: "06d99d26-28a2-42e7-9e08-d87a20e8586a", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/yjs/y-prosemirror/pull/267", + content: [ + { + type: "text", + text: "POC PR y-prosemirror", + styles: {}, + }, + ], + }, + { + type: "text", + text: " / BlockNote ", + }, + { + type: "link", + href: "https://github.com/TypeCellOS/BlockNote/pull/2849", + content: [ + { + type: "text", + text: "PR", + styles: {}, + }, + ], + }, + ], + }, + { + id: "b46f672b-f6a8-44e1-a9f6-c4a3652ed3a6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://blocknote-git-y-prosemirror-tests-matchnodes-typecell.vercel.app/collaboration/yhub", + content: [ + { + type: "text", + text: "POC Demo", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4161db5c-05d3-4451-a8b3-08977cd6f6a3", + type: "paragraph", + }, + { + id: "5c3e6b25-8b6b-4d58-9fa5-7025c2d1e916", + type: "paragraph", + content: [ + { + type: "text", + text: "This POC lets the diff decide ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "modify-in-place vs. replace", + styles: { + italic: true, + }, + }, + { + type: "text", + text: " via a caller-supplied predicate, so the boundary can be raised to a whole node. In this way, the diff produces two sibling ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "blockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: "s (allowed in schema) instead of two block-contents in one ", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + { + type: "text", + text: "blockContainer", + styles: { + code: true, + }, + }, + { + type: "text", + text: " (not allowed in schema)", + }, + { + type: "text", + text: ".", + styles: { + textColor: "rgb(31, 35, 40)", + backgroundColor: "rgb(255, 255, 255)", + }, + }, + ], + }, + { + id: "c3a4efd8-971f-4cb6-be5e-43b889ccb768", + type: "paragraph", + }, + { + id: "0d5d4caf-38bb-48e1-a5bc-803737023b61", + type: "paragraph", + content: [ + { + type: "text", + text: "Pros:", + }, + ], + }, + { + id: "3144fbf6-e488-4e90-a1e8-99a8bfd33ba8", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Relatively simple change", + }, + ], + }, + { + id: "14e399c6-a3ee-4781-a2ac-f4117d69b730", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'No editor schema change needed: duplicate nodes will only appear at the "block boundary"', + }, + ], + }, + { + id: "769a9e15-1209-4bf7-adc7-dc8faf4665ec", + type: "bulletListItem", + content: [ + { + type: "text", + text: "No data migration needed", + }, + ], + }, + { + id: "8018af78-6997-4767-a29d-428d3d97af0c", + type: "paragraph", + }, + { + id: "9bc9cb75-33a1-44ed-a074-fd0e6016ccae", + type: "paragraph", + content: [ + { + type: "text", + text: "Cons:", + }, + ], + }, + { + id: "63d5dbf8-56b9-422b-84c7-2b809ca39531", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Changing a block type (e.g. heading -> paragraph) will create a new blockcontainer node. This has some downsides:", + }, + ], + children: [ + { + id: "44acffd1-f97f-4330-81d8-55a0ae77a9dc", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'attribution: all nested children will be "copied", and attributed to the user who made the change', + }, + ], + }, + { + id: "5204487f-f071-47f5-a4f5-feff4293c92c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "diffing: the entire block will be shown as modified, including child blocks, when the parent block type was changed", + }, + ], + }, + { + id: "cf30c75b-6e9e-4318-aad3-38d2696dcebd", + type: "bulletListItem", + content: [ + { + type: "text", + text: "conflicts: simultaneous block-type changes and text / children edits won't merge nicely (will be LWW)", + }, + ], + }, + ], + }, + { + id: "0550d822-274c-4071-83e3-93d71d30de3c", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TBD: We might not be able to visualize it when two users both add a nested block, or, similar to the bullet point above, this would be a new blockcontainer node with same downsides of attribution / diffing / conflicts (but for adding / removing the first child block instead of for changing the block type)", + }, + ], + }, + { + id: "e9dd7478-b824-4c1f-a0d9-12fb5123804f", + type: "paragraph", + }, + { + id: "65aa733c-d9d9-44a4-937f-0041f4b48fc5", + type: "paragraph", + }, + { + id: "32d4f752-75fc-45b6-ad10-a4bc3bc47f65", + type: "paragraph", + }, + { + id: "50f9a169-419e-4bd1-af3a-2af89350524e", + type: "divider", + }, + { + id: "2e2cea10-5373-4ced-bfc0-d0eba8c4f59d", + type: "heading", + props: { + level: 2, + }, + content: [ + { + type: "text", + text: "Open tasks", + }, + ], + }, + { + id: "62986f28-3a36-4702-83f8-194a6a805dc0", + type: "paragraph", + content: [ + { + type: "text", + text: "The currently scoped remaining work has been categorized in 5 phases:", + }, + ], + }, + { + id: "bd772ad8-b12d-42d2-8082-be58366cbc3b", + type: "paragraph", + }, + { + id: "de0e3f48-8b39-44f4-8766-c8344dc97d79", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "1: Demo readiness", + }, + ], + }, + { + id: "cf5b7f55-53fe-4847-9ff9-2adff6beeee6", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Demo+readiness%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "4cd9804b-f320-4cfe-83a2-a25c46066104", + type: "paragraph", + }, + { + id: "6ca5e395-6654-4b05-b616-ef3bcdf96239", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Get the current work to a demoable and testable state", + }, + ], + }, + { + id: "c009551c-2f98-4ea8-8654-404e6b7444ac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "66971ae0-91f7-4c42-9c23-2e679527ab45", + type: "bulletListItem", + content: [ + { + type: "text", + text: "schema compatibility", + }, + ], + }, + { + id: "d328f8ed-1a83-489c-bca4-79bdf044fbac", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Add support for Table diffs to BlockNote and y-prosemirror", + }, + ], + children: [ + { + id: "f2d81783-ccb4-4d65-b94c-11c168899607", + type: "bulletListItem", + content: [ + { + type: "text", + text: "This has some unknowns and potentially needs a number of changes to ", + }, + { + type: "text", + text: "prosemirror-tables", + styles: { + code: true, + }, + }, + ], + }, + ], + }, + ], + }, + { + id: "c9ca8f7f-3a08-46cb-90bc-dc5696d7f260", + type: "paragraph", + }, + { + id: "e41f3d25-e219-4b3c-9f4b-faf64ba214d4", + type: "paragraph", + content: [ + { + type: "text", + text: "Estimate: depends on schema next step", + }, + ], + }, + { + id: "0b33469a-e047-4e86-846f-ee820583ce82", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "2: Stability", + }, + ], + }, + { + id: "58b960dd-d5f8-4af3-a8a7-37ffaebd3611", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Stability+%28diffs+%2F+versions%29%22", + content: [ + { + type: "text", + text: "View Issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "040949d3-65c5-43e6-ae57-a8f27c5c9f73", + type: "paragraph", + }, + { + id: "7937911d-a79c-4774-af90-67b342c7fa23", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Fix known issues in the current y-prosemirror binding", + }, + ], + }, + { + id: "0d7025db-3eb1-4bfc-b693-e885b4d60390", + type: "bulletListItem", + content: [ + { + type: "text", + text: "y-prosemirror at level that is comfortable to release as new major version", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "0981d2e1-9ab3-48f8-b1a1-4365a548b2b8", + type: "bulletListItem", + content: [ + { + type: "text", + text: "TODO Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "18b5622c-76e3-4e41-9659-820801967147", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Potential new items after testing demo", + }, + ], + }, + ], + }, + { + id: "e67ccfc3-10c1-4fb1-80a8-f0ffaf03ff91", + type: "paragraph", + }, + { + id: "625026e4-198e-4ad7-ab64-f884e82aaf9a", + type: "paragraph", + content: [ + { + type: "text", + text: "Original initial estimate Kevin: 5-8 days ", + }, + ], + }, + { + id: "eea91bef-61f2-4ac8-9d2c-559fdc528a30", + type: "paragraph", + content: [ + { + type: "text", + text: "2 XS / 2 S / 3 M / 1 L", + }, + ], + }, + { + id: "5b16f492-2b25-400c-987b-97b8a5eb4c90", + type: "paragraph", + content: [ + { + type: "text", + text: "Counted estimate: 2+(3-6)+(2-5) = 6-13 days, excluding unknowns for test phase improvements", + }, + ], + }, + { + id: "f2eb1b59-3909-4a6c-af47-b31662cabeae", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "3: BlockNote level features", + }, + ], + }, + { + id: "49904179-8e10-4770-9587-524966c4581c", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22BlockNote+level+features%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "a959c626-b4bc-4efa-af6e-b15aedb177b6", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Implement history panel", + }, + ], + }, + { + id: "14138376-912f-428f-ab74-643652c6bb62", + type: "bulletListItem", + content: [ + { + type: "text", + text: 'Update BlockNote APIs and documentation, make existing BlockNote APIs compatible with "diff views"', + }, + ], + }, + { + id: "69af46e1-d7c2-436f-8bae-dae836d3ae96", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "6787c2bc-28d5-4cf6-9bb0-1d05aceefddb", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "37fa84cf-570a-411a-9e96-c9e3bc9113d0", + type: "paragraph", + }, + { + id: "a450596d-8901-4712-8f3c-d4425a53d72b", + type: "paragraph", + content: [ + { + type: "text", + text: "1 L / 2 M", + }, + ], + }, + { + id: "859ed9e8-be8d-4582-ac11-f7195a5f1f7c", + type: "paragraph", + content: [ + { + type: "text", + text: "= 4-9 days", + }, + ], + }, + { + id: "2cb75462-1df9-4f94-bf7b-4ebb3de96fbb", + type: "paragraph", + }, + { + id: "81363d76-5daa-44f1-ac79-61b967f193db", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "4: Rollout", + }, + ], + }, + { + id: "ae6ac180-2492-41c6-9abb-2ae4dc00d4df", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Release+%2F+rollout%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "c6c65714-7d1b-43fc-8d23-5f6a452b4f18", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Migration guide", + }, + ], + }, + { + id: "361cbb94-ec74-482a-8c08-e2b938183064", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Stable release of y-prosemirror + yjs + lib0", + }, + ], + children: [ + { + id: "d010e74c-d72f-4d11-9d96-fac784b9ec6a", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Planned for end of August", + }, + ], + }, + ], + }, + { + id: "2eed8480-da1e-4f64-90ba-3ccf6a9df08f", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Release of BlockNote with (optional) new Yjs / y-prosemirror compatibility", + }, + ], + }, + { + id: "19b84b6a-5c1e-4637-917f-57282cff6612", + type: "paragraph", + }, + { + id: "e350b6ae-c8ff-4968-9149-419b08328923", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "5f7423fe-81d4-4a5b-8c1e-b9797308ec2b", + type: "bulletListItem", + content: [ + { + type: "text", + text: "none at this moment", + }, + ], + }, + ], + }, + { + id: "b5eab023-1223-4fd9-817f-3dbca47c5e7d", + type: "paragraph", + }, + { + id: "eade368f-5d90-49d6-b012-42e873d2dddf", + type: "paragraph", + content: [ + { + type: "text", + text: "3 M / 1 S", + }, + ], + }, + { + id: "630aaf9f-de91-4643-a2af-8e47f1c67ef2", + type: "paragraph", + content: [ + { + type: "text", + text: "= 3.5 - 6.5 days", + }, + ], + }, + { + id: "ab044a82-f38c-459a-99c7-342bb176e556", + type: "heading", + props: { + level: 3, + }, + content: [ + { + type: "text", + text: "5: Suggestions", + }, + ], + }, + { + id: "0e9ae6b9-85b1-4164-a137-e14323edb349", + type: "paragraph", + content: [ + { + type: "link", + href: "https://github.com/orgs/TypeCellOS/projects/14/views/1?filterQuery=category%3A%22Suggestions+%28track+changes%29%22", + content: [ + { + type: "text", + text: "View issues", + styles: {}, + }, + ], + }, + ], + }, + { + id: "2b12f097-fe31-43dd-97db-6bea1e33dfa2", + type: "paragraph", + }, + { + id: "e8cca260-bc7e-4a2c-834d-89a7d337e336", + type: "paragraph", + content: [ + { + type: "text", + text: "Specific features related to suggestions / track changes.", + }, + ], + }, + { + id: "116548de-8362-432b-af2c-af2702e16d5e", + type: "paragraph", + }, + { + id: "6476312d-aa12-4e5b-a093-bd36b90fddca", + type: "bulletListItem", + content: [ + { + type: "text", + text: "biggest blocker / unknown: ", + }, + ], + children: [ + { + id: "357f34c3-599e-4068-a196-83cc6930f2f0", + type: "bulletListItem", + content: [ + { + type: "text", + text: "delete suggestions", + }, + ], + }, + ], + }, + { + id: "3fe5c25b-87a8-4b97-bb3f-675bce4a7848", + type: "paragraph", + }, + { + id: "f2e94dbf-07ea-4f37-9a3c-438b0431618d", + type: "paragraph", + content: [ + { + type: "text", + text: "1 XL / 3 L / 4 M / 2 S", + }, + ], + }, + { + id: "057da6a6-7b99-478e-8629-14efcad4028d", + type: "paragraph", + content: [ + { + type: "text", + text: "= (6-15)+(4-8)+1 = 11-24 days", + }, + ], + }, + { + id: "c7e9c438-e13c-4a3d-811e-f057c53dc3cf", + type: "paragraph", + }, + { + id: "7241a164-f239-4720-9e02-918dcaab4bc0", + type: "paragraph", + }, + { + id: "344d2196-2024-4ba3-876e-432c653704fe", + type: "paragraph", + content: [ + { + type: "text", + text: "Total:", + styles: { + bold: true, + }, + }, + ], + }, + { + id: "5327a9d5-5f29-425b-aead-f1341654740c", + type: "paragraph", + content: [ + { + type: "text", + text: "24-50 days including suggestions", + }, + ], + }, + { + id: "d28b8635-26f0-4d76-a7fb-109ccf43c887", + type: "paragraph", + }, + { + id: "9d145c8b-8c95-481d-8e29-6659f7bcb80c", + type: "paragraph", + }, + ], +} as const; diff --git a/examples/07-collaboration/13-versioning-yjs14/tsconfig.json b/examples/07-collaboration/13-versioning-yjs14/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/13-versioning-yjs14/vite-env.d.ts b/examples/07-collaboration/13-versioning-yjs14/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/13-versioning-yjs14/vite.config.ts b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/07-collaboration/14-suggestion-gallery/.bnexample.json b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json new file mode 100644 index 0000000000..8433c71574 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": false, + "author": "yousefed", + "tags": ["Advanced", "Development", "Collaboration"], + "dependencies": { + "@blocknote/shared": "latest", + "@blocknote/xl-multi-column": "latest", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16" + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/README.md b/examples/07-collaboration/14-suggestion-gallery/README.md new file mode 100644 index 0000000000..aab7cb380a --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/README.md @@ -0,0 +1,18 @@ +# Suggestion Scenarios Gallery + +Browse the suggestion (track-changes) rendering scenarios interactively. Each +entry sets up a base document and applies a change in suggestion mode, so you can +see how insertions, deletions and type changes are visualized as a diff. + +The **Base** pane (left) is read-only and shows the document before the change. +The **Suggestion** pane (right) is editable — keep typing to create more +suggestions on top. + +These are the same scenarios covered by the y-prosemirror visual tests; the +per-scenario definitions live in `src/scenarios.ts` so the tests and this gallery +stay in sync. + +**Relevant Docs:** + +- [Editor Setup](/docs/editor-basics/setup) +- [Collaboration](/docs/collaboration/real-time-collaboration) diff --git a/examples/07-collaboration/14-suggestion-gallery/index.html b/examples/07-collaboration/14-suggestion-gallery/index.html new file mode 100644 index 0000000000..a8713956a6 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/index.html @@ -0,0 +1,14 @@ + + + + + Suggestion Scenarios Gallery + + + +
+ + + diff --git a/examples/07-collaboration/14-suggestion-gallery/main.tsx b/examples/07-collaboration/14-suggestion-gallery/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/14-suggestion-gallery/package.json b/examples/07-collaboration/14-suggestion-gallery/package.json new file mode 100644 index 0000000000..22b23a6e40 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/package.json @@ -0,0 +1,34 @@ +{ + "name": "@blocknote/example-collaboration-suggestion-gallery", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@blocknote/shared": "latest", + "@blocknote/xl-multi-column": "latest", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx new file mode 100644 index 0000000000..dfe38159be --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -0,0 +1,663 @@ +import "@blocknote/core/fonts/inter.css"; +import "@blocknote/mantine/style.css"; +import "./style.css"; + +import type { GalleryEditor } from "./gallerySchema"; +import { + createYjsVersioningAdapter, + SuggestionsExtension, + withCollaboration, +} from "@blocknote/core/y"; +import { BlockNoteView } from "@blocknote/mantine"; +import { useCreateBlockNote } from "@blocknote/react"; +import { Awareness } from "@y/protocols/awareness"; +import * as Y from "@y/y"; +import { useEffect, useState } from "react"; + +import { ScenarioErrorBoundary } from "./ErrorBoundary"; +import { gallerySchema } from "./gallerySchema"; +import { + buildSuggestionScenarioDocs, + cloneDoc, + createAttributionStore, + docFromBlocks, +} from "./scenarioDocs"; +import { scenarios, SuggestionScenario } from "./scenarios"; + +type Mode = "suggestions" | "versioning"; + +function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness { + const awareness = new Awareness(doc); + awareness.setLocalStateField("user", { name, color }); + return 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" }; + +type AttributionManager = ReturnType; + +type SuggestionAuthor = { + id: string; + label: string; + user: { name: string; color: string }; + apply: (editor: GalleryEditor) => void; +}; + +/** + * The authors making suggestions from the base — one for a single scenario, two + * (A and B) for a concurrent one. + */ +function suggestionAuthors(scenario: SuggestionScenario): SuggestionAuthor[] { + if (scenario.kind === "single") { + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.apply, + }, + ]; + } + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.applyA, + }, + { + id: "B", + label: "User B (editable)", + user: USER_B, + apply: scenario.applyB, + }, + ]; +} + +/** + * Suggestions mode for any scenario — Base (read-only) + one editable pane per + * author + (for a concurrent scenario) a read-only Merged pane that replays every + * author's suggestions live. Docs are built up front, so each editor just enables + * suggestion mode and applies its change on mount. + */ +function SuggestionsView({ scenario }: { scenario: SuggestionScenario }) { + const [setup] = useState(() => { + const base = docFromBlocks(scenario.initial); + return { base, baseAwareness: new Awareness(base) }; + }); + + const baseEditor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: setup.base.get("doc"), + provider: { awareness: setup.baseAwareness }, + user: { name: "Base", color: "#888888" }, + }, + }), + ); + + // Editing the base resets the suggestions — remount `` (fresh + // clones of the new base, no suggestion re-applied) via the `nonce` key, the + // same way editing Version 1 resets the versioning view. + const [nonce, setNonce] = useState(0); + useEffect(() => { + const onBaseEdit = () => setNonce((n) => n + 1); + setup.base.on("update", onBaseEdit); + return () => setup.base.off("update", onBaseEdit); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const authors = suggestionAuthors(scenario); + const paneCount = 1 + authors.length + (authors.length > 1 ? 1 : 0); + return ( +
= 4 ? " bn-gallery-editors--four" : "") + } + > +
+
Base (editable)
+ +
+ +
+ ); +} + +/** + * The author panes + (for a concurrent scenario) the Merged pane, built from a + * snapshot of the base. `applyInitial` applies each author's suggestion on the + * first build; a reset (base edited) leaves them clean, mirroring the versioning + * view's user panes. + */ +function SuggestionPanes({ + base, + authors, + applyInitial, +}: { + base: Y.Doc; + authors: SuggestionAuthor[]; + applyInitial: boolean; +}) { + const [setup] = useState(() => { + const docs = buildSuggestionScenarioDocs( + base, + authors.map((a) => a.id), + ); + return { + baseDoc: docs.baseDoc, + combined: authors.map((a, i) => ({ ...a, ...docs.authors[i] })), + merged: docs.merged, + }; + }); + + return ( + <> + {setup.combined.map((a) => ( + + ))} + {setup.merged && ( + ({ + id: a.id, + doc: a.suggestionDoc, + }))} + /> + )} + + ); +} + +/** + * One editable author pane in suggestion mode: enables suggestions + applies the + * author's change on mount; edits land in `suggestionDoc` as tracked changes. + */ +function UserSuggestion({ + baseDoc, + suggestionDoc, + manager, + user, + apply, + label, +}: { + baseDoc: Y.Doc; + suggestionDoc: Y.Doc; + manager: AttributionManager; + user: { name: string; color: string }; + apply?: (editor: GalleryEditor) => void; + label: string; +}) { + const [setup] = useState(() => ({ + awareness: makeAwareness(baseDoc, user.name, user.color), + })); + + const editor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: baseDoc.get("doc"), + provider: { awareness: setup.awareness }, + suggestionDoc, + attributionManager: manager, + user, + }, + }), + ); + + useEffect(() => { + editor.getExtension(SuggestionsExtension)!.enableSuggestions(); + apply?.(editor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {label} +
+ +
+ ); +} + +/** + * The read-only Merged pane (concurrent only): a viewer editor that replays each + * author's suggestions, forwarded from their docs and tagged by author id, so any + * new suggestion shows up live. + */ +function MergedSuggestion({ + baseDoc, + merged, + authorDocs, +}: { + baseDoc: Y.Doc; + merged: { doc: Y.Doc; manager: AttributionManager }; + authorDocs: { id: string; doc: Y.Doc }[]; +}) { + const [setup] = useState(() => ({ awareness: new Awareness(baseDoc) })); + + const editor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: baseDoc.get("doc"), + provider: { awareness: setup.awareness }, + suggestionDoc: merged.doc, + attributionManager: merged.manager, + user: { name: "Merged", color: "#666666" }, + }, + }), + ); + + useEffect(() => { + editor.getExtension(SuggestionsExtension)!.enableSuggestions(); + const offs = authorDocs.map(({ id, doc }) => { + const onUpdate = (update: Uint8Array) => + Y.applyUpdate(merged.doc, update, id); + doc.on("update", onUpdate); + return () => doc.off("update", onUpdate); + }); + // Pull in suggestions already applied on mount. + authorDocs.forEach(({ id, doc }) => + Y.applyUpdate(merged.doc, Y.encodeStateAsUpdate(doc), id), + ); + return () => offs.forEach((off) => off()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
Merged (read-only)
+ +
+ ); +} + +type VersioningUser = { + id: string; + label: string; + user: { name: string; color: string }; + apply: (editor: GalleryEditor) => void; +}; + +/** + * The editable "user" versions a scenario merges into Version 2 — one for a + * single-user scenario, two (A and B) for a concurrent one. + */ +function versioningUsers(scenario: SuggestionScenario): VersioningUser[] { + if (scenario.kind === "single") { + return [ + { + id: "A", + label: "Version 2 (editable)", + user: USER_A, + apply: scenario.apply, + }, + ]; + } + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.applyA, + }, + { + id: "B", + label: "User B (editable)", + user: USER_B, + apply: scenario.applyB, + }, + ]; +} + +/** + * Versioning mode for any scenario — Version 1 (base) + one editable pane per + * "user" + a read-only Diff. Version 2 is the live CRDT merge of the user docs: + * editing any user re-merges (and re-diffs); editing Version 1 resets every user + * back to a fresh clone (via the `nonce` remount). + */ +function VersioningView({ scenario }: { scenario: SuggestionScenario }) { + const [setup] = useState(() => { + const beforeDoc = docFromBlocks(scenario.initial); + return { + beforeDoc, + beforeAwareness: makeAwareness(beforeDoc, "Version 1", "#888888"), + users: versioningUsers(scenario), + }; + }); + + const beforeEditor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: setup.beforeDoc.get("doc"), + provider: { awareness: setup.beforeAwareness }, + user: { name: "Version 1", color: "#888888" }, + }, + }), + ); + + // Editing Version 1 resets the merge — remount `` (fresh clones + // of the new base, no change re-applied) via the `nonce` key. + const [nonce, setNonce] = useState(0); + useEffect(() => { + const onVersion1Edit = () => setNonce((n) => n + 1); + setup.beforeDoc.on("update", onVersion1Edit); + return () => setup.beforeDoc.off("update", onVersion1Edit); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
1 + ? "bn-gallery-editors--four" + : "bn-gallery-editors--three") + } + > +
+
Version 1 (editable)
+ +
+ +
+ ); +} + +/** + * The user panes + the Diff. Each user gets an editable editor on its own clone + * of the base; their edits are forwarded into `afterDoc` (a CRDT merge), which + * the read-only Diff shows against the base. `applyInitial` applies each user's + * change on the first build; a reset (Version 1 edited) leaves them clean. + */ +function VersionMerge({ + beforeDoc, + users, + applyInitial, +}: { + beforeDoc: Y.Doc; + users: VersioningUser[]; + 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, + ); + return { + userDocs: users.map(() => cloneDoc(beforeDoc)), + afterDoc, + attrs, + diffAwareness: new Awareness(afterDoc), + }; + }); + + const diffEditor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: setup.afterDoc.get("doc"), + provider: { awareness: setup.diffAwareness }, + user: USER_A, + }, + }), + ); + + 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); + 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), + ); + + const adapter = createYjsVersioningAdapter( + diffEditor, + setup.afterDoc.get("doc"), + ); + const renderDiff = () => + adapter.preview.enterPreview( + Y.encodeStateAsUpdateV2(setup.afterDoc), + Y.encodeStateAsUpdateV2(beforeDoc), + setup.attrs, + ); + renderDiff(); + setup.afterDoc.on("update", renderDiff); + + return () => { + offs.forEach((off) => off()); + setup.afterDoc.off("update", renderDiff); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + {users.map((u, i) => ( + + ))} +
+
Diff (read-only)
+ +
+ + ); +} + +/** + * One editable "user" version: an editor on `doc` (a base clone), with the + * scenario change applied on mount (unless this is a reset, when `apply` is + * omitted). Edits flow into the merge through `doc`. + */ +function UserVersion({ + doc, + user, + apply, + label, +}: { + doc: Y.Doc; + user: { name: string; color: string }; + apply?: (editor: GalleryEditor) => void; + label: string; +}) { + const [setup] = useState(() => ({ + awareness: makeAwareness(doc, user.name, user.color), + })); + + const editor = useCreateBlockNote( + withCollaboration({ + schema: gallerySchema, + collaboration: { + fragment: doc.get("doc"), + provider: { awareness: setup.awareness }, + user, + }, + }), + ); + + useEffect(() => { + apply?.(editor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {label} +
+ +
+ ); +} + +const SEVERITY = { + high: { icon: "🔴", rank: 0 }, + low: { icon: "🟡", rank: 1 }, + info: { icon: "🔵", rank: 2 }, +} as const; + +// The most-severe note across a scenario's feedback — a known crash counts as +// high — or null if it has none. Drives the sidebar indicator. +function topSeverity(s: SuggestionScenario): "high" | "low" | "info" | null { + const fb = s.feedback ?? []; + if (s.knownCrash || fb.some((f) => f.severity === "high")) { + return "high"; + } + if (fb.some((f) => f.severity === "low")) { + return "low"; + } + return fb.some((f) => f.severity === "info") ? "info" : null; +} + +function severityBadge(s: SuggestionScenario): string { + const sev = topSeverity(s); + return sev ? SEVERITY[sev].icon + " " : ""; +} + +export default function App() { + const [selectedId, setSelectedId] = useState(scenarios[0].id); + const [mode, setMode] = useState("versioning"); + const selected = scenarios.find((s) => s.id === selectedId)!; + + const categories = [...new Set(scenarios.map((s) => s.category))]; + + return ( +
+ + +
+
+
+

{selected.title}

+

{selected.description}

+
+
+ {(["suggestions", "versioning"] as Mode[]).map((m) => ( + + ))} +
+
+ + {selected.feedback && selected.feedback.length > 0 && ( +
+
+ {selected.feedback.some((f) => f.severity !== "info") + ? "Known issues" + : "Notes"} +
+ {[...selected.feedback] + .sort( + (a, b) => SEVERITY[a.severity].rank - SEVERITY[b.severity].rank, + ) + .map((f, i) => ( +
+ + {f.severity} + + {f.note} +
+ ))} +
+ )} + + + {mode === "versioning" ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx b/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx new file mode 100644 index 0000000000..bf2cf02923 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { Component, ErrorInfo, ReactNode } from "react"; + +/** + * Catches render/setup errors from a scenario so one crashing case (e.g. a + * concurrent table merge that hits prosemirror-tables' `fixTables` bug) shows an + * inline message instead of white-screening the whole gallery. Reset via `key`. + */ +export class ScenarioErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // eslint-disable-next-line no-console + console.error("Scenario crashed:", error, info); + } + + render() { + if (this.state.error) { + return ( +
+ This scenario crashed. +
+            {this.state.error.message}
+          
+
+ ); + } + return this.props.children; + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts b/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts new file mode 100644 index 0000000000..80160e5d41 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/gallerySchema.ts @@ -0,0 +1,34 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + PartialBlock, + withPageBreak, +} from "@blocknote/core"; +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 + * shared test fixtures both build on this so the two never drift. + */ +export const gallerySchema = withMultiColumn( + withPageBreak(BlockNoteSchema.create()), +); + +type GallerySchema = typeof gallerySchema; + +/** A `BlockNoteEditor` typed for the gallery schema (columns + page break). */ +export type GalleryEditor = BlockNoteEditor< + GallerySchema["blockSchema"], + GallerySchema["inlineContentSchema"], + GallerySchema["styleSchema"] +>; + +/** A `PartialBlock` typed for the gallery schema. */ +export type GalleryPartialBlock = PartialBlock< + GallerySchema["blockSchema"], + GallerySchema["inlineContentSchema"], + GallerySchema["styleSchema"] +>; diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts new file mode 100644 index 0000000000..6851786aba --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts @@ -0,0 +1,131 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { blocksToYDoc } from "@blocknote/core/y"; +import * as Y from "@y/y"; + +import { + gallerySchema, + type GalleryEditor, + type GalleryPartialBlock, +} from "./gallerySchema"; + +const FRAGMENT = "doc"; + +// A headless editor, used only for its (default) schema when building Y.Docs +// from blocks — `blocksToYDoc` needs an editor to resolve the schema. Created +// lazily on first use so importing this module has no side effects. +let schemaEditor: GalleryEditor | undefined; +const getSchemaEditor = () => + (schemaEditor ??= BlockNoteEditor.create({ schema: gallerySchema })); + +/** + * Build a fully-seeded Y.Doc from blocks, **synchronously**. No editor is bound, + * so an editor later created on this doc ADOPTS the content instead of writing a + * competing initial blockGroup. This is the gate — but as the default path, which + * lets the views skip the seed-then-poll-then-sync dance entirely. + */ +export function docFromBlocks(blocks: GalleryPartialBlock[]): Y.Doc { + return blocksToYDoc(getSchemaEditor(), blocks, FRAGMENT); +} + +/** + * Clone a Y.Doc into a fresh one. Preserves the source's Y ids (so a later diff + * shows only the real changes, not a full replace) and keeps a single root (the + * fresh doc has no init blockGroup to collide with). + */ +export function cloneDoc( + source: Y.Doc, + opts?: { isSuggestionDoc?: boolean }, +): Y.Doc { + const doc = opts?.isSuggestionDoc + ? new Y.Doc({ isSuggestionDoc: true }) + : new Y.Doc(); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(source)); + return doc; +} + +/** + * The docs + managers for a suggestion scenario, built against a snapshot clone + * of `sourceBase` — so the caller can keep an editable "live" base and rebuild + * these on every base edit. One suggestion doc + manager per author + * (`authorIds`), whose tracked edits are attributed to their id — so each mark + * carries a non-empty `data-user-ids` and the hover tooltip shows. With more than + * one author a `merged` viewer doc is added that replays every author's + * suggestions, tagged by the Yjs transaction origin. Client ids are pinned so the + * merge tiebreak is stable. + */ +export function buildSuggestionScenarioDocs( + sourceBase: Y.Doc, + authorIds: string[], +) { + const baseDoc = cloneDoc(sourceBase); + baseDoc.clientID = 1; + + const authors = authorIds.map((id, i) => { + const suggestionDoc = cloneDoc(baseDoc, { isSuggestionDoc: true }); + suggestionDoc.clientID = i + 2; + const manager = Y.createAttributionManagerFromDiff(baseDoc, suggestionDoc, { + attrs: createAttributionStore(suggestionDoc, (tr) => + tr.local ? id : null, + ), + }); + manager.suggestionMode = true; + return { id, suggestionDoc, manager }; + }); + + const merged = + authorIds.length > 1 + ? (() => { + const doc = cloneDoc(baseDoc, { isSuggestionDoc: true }); + doc.clientID = authorIds.length + 2; + const manager = Y.createAttributionManagerFromDiff(baseDoc, doc, { + attrs: createAttributionStore(doc, (tr) => + authorIds.includes(String(tr.origin)) ? String(tr.origin) : null, + ), + }); + manager.suggestionMode = false; + return { doc, manager }; + })() + : undefined; + + return { baseDoc, authors, merged }; +} + +/** + * In-memory attribution store — records the author of each transaction into a + * mutable `Y.Attributions` 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`. + */ +export function createAttributionStore( + doc: Y.Doc, + resolveUserId: (tr: any) => string | null, +): Y.Attributions { + const attrs = new Y.Attributions(); + doc.on("beforeObserverCalls", (tr: any) => { + const userId = resolveUserId(tr); + if (userId == null) { + return; + } + if (!tr.insertSet.isEmpty()) { + Y.insertIntoIdMap( + attrs.inserts, + Y.createIdMapFromIdSet(tr.insertSet, [ + Y.createContentAttribute("insert", userId), + ]), + ); + } + if (!tr.deleteSet.isEmpty()) { + Y.insertIntoIdMap( + attrs.deletes, + Y.createIdMapFromIdSet(tr.deleteSet, [ + Y.createContentAttribute("delete", userId), + ]), + ); + } + }); + return attrs; +} + +// (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 new file mode 100644 index 0000000000..f01d84cc60 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -0,0 +1,1671 @@ +import { testDocument } from "@blocknote/shared/testDocument"; + +import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; + +/** + * A browsable suggestion scenario. + * + * `single` scenarios set up one suggesting editor (diffed against the base): + * 1. the editor is seeded with `initial`, + * 2. the base is synced into the suggestion doc (so it becomes the "before"), + * 3. suggestion mode is enabled, + * 4. `apply(editor)` performs the change — which now renders as a diff. + * + * These are the same scenarios exercised by the y-prosemirror visual tests; the + * goal is to share these definitions so the tests and this gallery never drift. + * (Test wiring stays in the fixtures; only the per-scenario data lives here.) + */ +/** + * A tracked note for a scenario, surfaced in the gallery so this example doubles + * as a living list of behavior worth knowing. `high` = wrong merge result, crash, + * or data loss; `low` = cosmetic / attribution quirk or a nice-to-have; `info` = + * a neutral note about expected behavior (not a problem). Keep the issue-level + * notes in sync with the `TODO` / `KNOWN LIMITATION` / `test.skip` / `test.fails` + * notes in the y-prosemirror e2e tests — that's where each is proven. + */ +export type Feedback = { + severity: "info" | "low" | "high"; + note: string; +}; + +export type SingleScenario = { + kind: "single"; + id: string; + title: string; + category: string; + description: string; + /** Blocks the document starts with (the "before"). */ + initial: GalleryPartialBlock[]; + /** The change to make in suggestion mode (the "after"). */ + apply: (editor: GalleryEditor) => void; + /** Set when the scenario is known to throw, so the gallery can warn. */ + knownCrash?: boolean; + /** Known issues / improvement points, shown in the gallery. */ + feedback?: Feedback[]; +}; + +/** + * A two-user concurrent scenario. Both users start from `initial`, then User A + * runs `applyA` and User B runs `applyB` independently in suggestion mode; the + * tests/gallery merge the two edits through the Yjs CRDT. + */ +export type ConcurrentScenario = { + kind: "concurrent"; + id: string; + title: string; + category: string; + description: string; + /** Blocks both users start from (the shared "before"). */ + initial: GalleryPartialBlock[]; + /** User A's change (suggestion mode). */ + applyA: (editor: GalleryEditor) => void; + /** User B's change (suggestion mode). */ + applyB: (editor: GalleryEditor) => void; + /** Set when the scenario is known to throw, so the gallery can warn. */ + knownCrash?: boolean; + /** Known issues / improvement points, shown in the gallery. */ + feedback?: Feedback[]; +}; + +export type SuggestionScenario = SingleScenario | ConcurrentScenario; + +// Inline SVG data URLs — avoid a network fetch for image sources. `IMG_SRC_BASE` +// (red) and `IMG_SRC_NEW` (teal) are exported so tests can poll against the exact +// URL a scenario sets, with no chance of the two drifting. +export const IMG_SRC_BASE = + "data:image/svg+xml;utf8,"; +export const IMG_SRC_NEW = + "data:image/svg+xml;utf8,"; + +// Shared 2×2 table baseline used by most of the table scenarios. +const TABLE_2X2 = { + id: "table", + type: "table" as const, + content: { + type: "tableContent" as const, + rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }], + }, +}; + +// Simulate a real keypress at the editor's current selection by routing a +// synthetic keydown through ProseMirror's keymap — the same path a user's +// Enter / Backspace takes, so split/merge behave exactly as they do for a +// person typing (BlockNote has no public "split block" / "merge blocks" command). +function pressKey(editor: GalleryEditor, key: string) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { key }); + view.someProp("handleKeyDown", (f) => f(view, event)); +} + +// The document position at the start of the first occurrence of `text`. Lets a +// scenario drop the cursor mid-block before splitting (BlockNote's +// `setTextCursorPosition` only offers "start" / "end"). +function posBeforeText(editor: GalleryEditor, text: string): number { + let pos = -1; + editor.prosemirrorState.doc.descendants((node, nodePos) => { + if (pos === -1 && node.isText && node.text) { + const idx = node.text.indexOf(text); + if (idx !== -1) { + pos = nodePos + idx; + } + } + return pos === -1; + }); + return pos; +} + +export const scenarios: SuggestionScenario[] = [ + { + kind: "single", + id: "add-heading", + title: "Add heading", + category: "Add / remove blocks", + description: + "Insert a level-1 heading into an empty document. The inserted block is " + + "highlighted in the author's color.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "h0", + type: "heading", + props: { level: 1 }, + content: "New heading", + }, + ]), + }, + { + kind: "single", + id: "delete-image", + title: "Delete image", + category: "Add / remove blocks", + description: + "Delete an image block. Blocks with no inline content (image, divider, …) " + + "are flagged with a 'Deleted' card rather than struck through.", + initial: [ + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ], + apply: (editor) => editor.removeBlocks(["img"]), + }, + { + kind: "single", + id: "add-bullet", + title: "Add bullet item", + category: "Add / remove blocks", + description: "Insert a bullet list item into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { id: "b0", type: "bulletListItem", content: "New bullet" }, + ]), + }, + { + kind: "single", + id: "add-numbered", + title: "Add numbered item", + category: "Add / remove blocks", + description: "Insert a numbered list item into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { id: "n0", type: "numberedListItem", content: "New numbered" }, + ]), + }, + { + kind: "single", + id: "add-nested-bullets", + feedback: [ + { + severity: "low", + 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).", + }, + ], + title: "Add nested bullets", + category: "Add / remove blocks", + description: + "Insert a three-level nested bullet list into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "l0", + type: "bulletListItem", + content: "Level 0", + children: [ + { + id: "l1", + type: "bulletListItem", + content: "Level 1", + children: [ + { id: "l2", type: "bulletListItem", content: "Level 2" }, + ], + }, + ], + }, + ]), + }, + { + kind: "single", + id: "add-colored-block", + feedback: [ + { + severity: "low", + note: "The nested child loses the parent's background tint — the :has() background selector breaks when the inserted content is wrapped in .", + }, + ], + title: "Add colored block with child", + category: "Add / remove blocks", + description: + "Insert a blue-background paragraph with a nested child into an empty " + + "document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "c0", + type: "paragraph", + props: { backgroundColor: "blue" }, + content: "Colored parent", + children: [{ id: "c1", type: "paragraph", content: "Child block" }], + }, + ]), + }, + { + kind: "single", + id: "nest-bullet-existing", + feedback: [ + { + severity: "low", + 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.", + }, + ], + title: "Nest a bullet under another", + category: "Add / remove blocks", + description: "Nest the second bullet under the first.", + initial: [ + { id: "p", type: "bulletListItem", content: "Parent" }, + { id: "c", type: "bulletListItem", content: "Child" }, + ], + apply: (editor) => { + editor.setTextCursorPosition("c", "start"); + editor.nestBlock(); + }, + }, + { + kind: "single", + id: "add-paragraph-after", + title: "Add paragraph after a block", + category: "Add / remove blocks", + description: "Insert a paragraph after an existing heading.", + initial: [ + { id: "h0", type: "heading", props: { level: 1 }, content: "Title" }, + ], + apply: (editor) => + editor.insertBlocks( + [{ id: "p0", type: "paragraph", content: "Body text" }], + "h0", + "after", + ), + }, + { + kind: "single", + id: "remove-paragraph", + title: "Remove a paragraph", + category: "Add / remove blocks", + description: + "Delete the body paragraph from a heading + paragraph document.", + initial: [ + { id: "h0", type: "heading", props: { level: 1 }, content: "Title" }, + { id: "p0", type: "paragraph", content: "Body text" }, + ], + apply: (editor) => editor.removeBlocks(["p0"]), + }, + { + kind: "single", + id: "remove-all", + title: "Remove the only block", + category: "Add / remove blocks", + description: "Delete the single block in the document.", + initial: [{ id: "p0", type: "paragraph", content: "Only block" }], + apply: (editor) => editor.removeBlocks(["p0"]), + }, + { + kind: "single", + 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.", + }, + ], + title: "Delete a nested block", + category: "Add / remove blocks", + description: "Delete the nested child of a parent block.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.removeBlocks(["child"]), + }, + { + kind: "single", + id: "delete-parent", + title: "Delete a parent block", + category: "Add / remove blocks", + description: "Delete a parent block together with its nested child.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.removeBlocks(["parent"]), + }, + { + kind: "single", + id: "delete-mixed-parent", + title: "Delete parent with mixed children", + category: "Add / remove blocks", + description: + "Delete a parent block whose children are a paragraph and an image.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [ + { id: "p1", type: "paragraph", content: "Nested paragraph" }, + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ], + }, + ], + apply: (editor) => editor.removeBlocks(["parent"]), + }, + { + kind: "single", + id: "delete-code-block", + title: "Delete a code block", + category: "Add / remove blocks", + description: "Delete a code block.", + initial: [{ id: "code", type: "codeBlock", content: "const x = 1;" }], + apply: (editor) => editor.removeBlocks(["code"]), + }, + { + kind: "single", + id: "insert-divider", + title: "Insert a divider", + category: "Add / remove blocks", + description: "Insert a divider between two paragraphs.", + initial: [ + { id: "above", type: "paragraph", content: "Above" }, + { id: "below", type: "paragraph", content: "Below" }, + ], + apply: (editor) => + editor.insertBlocks([{ type: "divider" }], "above", "after"), + }, + { + kind: "single", + id: "delete-divider", + title: "Delete a divider", + category: "Add / remove blocks", + description: "Delete a divider (a block with no inline content).", + initial: [{ id: "hr", type: "divider" }], + apply: (editor) => editor.removeBlocks(["hr"]), + }, + { + kind: "single", + id: "insert-image", + title: "Insert an image", + category: "Add / remove blocks", + description: "Insert an image block into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ]), + }, + { + kind: "single", + id: "type-list-to-paragraph", + title: "List item → paragraph", + category: "Type changes", + description: + "Demote a bullet list item to a paragraph. The inline content is preserved; " + + "only the wrapping block type changes (old struck through, new inserted).", + initial: [ + { id: "block-hello", type: "bulletListItem", content: "hello world" }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph" }); + }, + }, + { + kind: "single", + id: "type-paragraph-to-heading", + title: "Paragraph → heading", + category: "Type changes", + description: + "Promote a paragraph to a level-1 heading. The inline content is preserved; " + + "the original is struck through and the new heading inserted beside it.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + }, + + // --- Basic text --- + { + kind: "single", + id: "text-rename-word", + feedback: [ + { + severity: "low", + note: "The diff looks a bit garbled — individual characters are suggested mid-word. Real-world typing (character-by-character) wouldn't show this, but a programmatic updateBlock (as in this demo) does. A coarser, word-based diff would fix it.", + }, + ], + title: "Rename a word", + category: "Basic text", + description: + "Replace 'world' with 'universe'. The diff splits the changed run into " + + "struck-through deletions and inserted characters.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello universe", + }); + }, + }, + { + kind: "single", + id: "text-add-bold", + title: "Add bold", + category: "Basic text", + description: + "Bold the word 'world' — a format-only change, tracked via the " + + "modification marker rather than an insert/delete.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }); + }, + }, + { + kind: "single", + id: "text-remove-bold", + title: "Remove bold", + category: "Basic text", + description: + "Strip bold from an already-bold 'world' — the symmetric format-only " + + "removal.", + initial: [ + { + id: "block-hello", + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello world", + }); + }, + }, + { + kind: "single", + id: "text-add-italic-to-bold", + feedback: [], + title: "Add italic over bold", + category: "Basic text", + description: + "Add italic to a word that is already bold, keeping both marks.", + initial: [ + { + id: "block-hello", + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true, italic: true } }, + ], + }); + }, + }, + + // --- Move blocks --- + { + kind: "single", + id: "move-paragraph-up", + title: "Move paragraph up", + category: "Move blocks", + description: + "Move the middle paragraph above the first — a delete at the old position " + + "and an insert at the new one.", + initial: [ + { id: "first", type: "paragraph", content: "First" }, + { id: "middle", type: "paragraph", content: "Middle" }, + { id: "last", type: "paragraph", content: "Last" }, + ], + apply: (editor) => editor.moveBlocksUp("middle"), + }, + { + kind: "single", + id: "move-paragraph-with-children", + title: "Move paragraph with children", + category: "Move blocks", + description: + "Move a parent paragraph (and its nested child) up one position.", + initial: [ + { id: "first", type: "paragraph", content: "First" }, + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.moveBlocksUp("parent"), + feedback: [], + }, + + // --- Nesting --- + { + kind: "single", + 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.", + }, + ], + title: "Indent a block", + category: "Nesting", + description: + "Nest N1 under N0 (indent). The moved block is re-inserted nested under " + + "its new parent.", + initial: [ + { id: "n0", type: "paragraph", content: "N0" }, + { id: "n1", type: "paragraph", content: "N1" }, + ], + apply: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + }, + { + 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.", + }, + ], + category: "Nesting", + description: "Un-nest N1 out of N0 (outdent) back to a top-level sibling.", + initial: [ + { + id: "n0", + type: "paragraph", + content: "N0", + children: [{ id: "n1", type: "paragraph", content: "N1" }], + }, + ], + apply: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.unnestBlock(); + }, + }, + { + kind: "single", + 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.", + }, + ], + title: "Change type of a parent block", + category: "Nesting", + description: + "Change a parent paragraph (with a nested child) to a heading; the child " + + "nesting is preserved.", + initial: [ + { + id: "n0", + type: "paragraph", + content: "N0", + children: [{ id: "n1", type: "paragraph", content: "N1" }], + }, + ], + apply: (editor) => { + const [parent] = editor.document; + editor.updateBlock(parent, { type: "heading", props: { level: 1 } }); + }, + }, + + // --- Prop changes --- + { + 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).", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + props: { textAlignment: "center" }, + }); + }, + }, + { + 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.", + initial: [ + { + id: "block-hello", + type: "heading", + props: { level: 1 }, + content: "hello world", + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 2 } }); + }, + }, + { + 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).", + initial: [ + { + id: "block-image", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 200 }, + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "image", + props: { previewWidth: 400 }, + }); + }, + }, + { + 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.", + initial: [ + { + id: "block-image", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 200 }, + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "image", props: { url: IMG_SRC_NEW } }); + }, + }, + + // --- Tables --- + { + kind: "single", + id: "table-add-row", + title: "Add row", + category: "Tables", + description: "Add a third row to a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + kind: "single", + id: "table-add-column", + title: "Add column", + category: "Tables", + description: "Add a third column to a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + { + kind: "single", + id: "table-remove-row", + title: "Remove row", + category: "Tables", + description: "Remove the last row from a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1"] }], + }, + }), + }, + { + kind: "single", + id: "table-remove-column", + title: "Remove column", + category: "Tables", + description: "Remove the last column from a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1"] }, { cells: ["A2"] }], + }, + }), + }, + { + kind: "single", + id: "table-edit-cell", + title: "Edit a cell", + category: "Tables", + description: "Edit the text of the top-left cell.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1 edited", "B1"] }, { cells: ["A2", "B2"] }], + }, + }), + }, + { + kind: "single", + id: "table-column-color", + title: "Highlight a column", + category: "Tables", + description: "Set a green background on the first column's cells.", + 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.", + }, + ], + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { backgroundColor: "green" }, + content: ["A1"], + }, + { type: "tableCell", content: ["B1"] }, + ], + }, + { + cells: [ + { + type: "tableCell", + props: { backgroundColor: "green" }, + content: ["A2"], + }, + { type: "tableCell", content: ["B2"] }, + ], + }, + ], + }, + }), + }, + { + kind: "single", + id: "table-merge-cells", + feedback: [ + { + severity: "low", + note: "The diff shows a phantom extra 'deleted column' that isn't actually part of the merge.", + }, + ], + title: "Merge cells", + category: "Tables", + description: "Merge the two top-row cells into one (colspan 2).", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { colspan: 2 }, + content: ["A1+B1"], + }, + ], + }, + { cells: ["A2", "B2"] }, + ], + }, + }), + }, + { + kind: "single", + id: "table-split-cell", + title: "Split a merged cell", + category: "Tables", + description: "Split a merged top-row cell back into two.", + initial: [ + { + id: "table", + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { colspan: 2 }, + content: ["A1+B1"], + }, + ], + }, + { cells: ["A2", "B2"] }, + ], + }, + }, + ], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }], + }, + }), + }, + + // --- Concurrent (two users, merged via the Yjs CRDT) --- + { + kind: "concurrent", + id: "concurrent-typo-vs-delete", + feedback: [], + title: "Fix typo vs delete word", + category: "Basic text", + description: + "A fixes a typo while B deletes the word; the CRDT merges both.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello wrold" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph", content: "hello world" }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph", content: "hello " }); + }, + }, + { + kind: "concurrent", + id: "concurrent-bold-vs-italic", + title: "Bold vs italic", + category: "Basic text", + description: + "A bolds a word while B italicises it; both marks land after the merge.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { italic: true } }, + ], + }); + }, + }, + { + kind: "concurrent", + id: "concurrent-indent-cascade", + feedback: [ + { + 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).", + }, + ], + title: "Cascading indents", + category: "Nesting", + description: "A indents N1 while B indents N2.", + initial: [ + { id: "n0", type: "paragraph", content: "N0" }, + { id: "n1", type: "paragraph", content: "N1" }, + { id: "n2", type: "paragraph", content: "N2" }, + ], + applyA: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.setTextCursorPosition("n2", "start"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-nest-both-under-n0", + feedback: [ + { + severity: "info", + 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.", + }, + ], + title: "Both nest a new block under N0", + category: "Nesting", + description: + "A and B each insert a sibling after N0 and nest it (known-tricky merge).", + initial: [{ id: "n0", type: "paragraph", content: "N0" }], + applyA: (editor) => { + editor.insertBlocks( + [{ id: "n1", type: "paragraph", content: "N1" }], + "n0", + "after", + ); + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.insertBlocks( + [{ id: "n2", type: "paragraph", content: "N2" }], + "n0", + "after", + ); + editor.setTextCursorPosition("n2", "start"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-textcolor-vs-bgcolor", + title: "Text color vs background color", + category: "Prop changes", + description: + "A sets text color red while B sets background yellow; both apply.", + 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, { + type: "paragraph", + props: { textColor: "red" }, + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + props: { backgroundColor: "yellow" }, + }); + }, + }, + { + kind: "concurrent", + 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.", + }, + ], + title: "Heading vs list item", + category: "Type changes", + description: + "A turns the block into a heading while B turns it into a list item.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "bulletListItem" }); + }, + }, + { + kind: "concurrent", + id: "concurrent-text-vs-heading", + feedback: [ + { + 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.", + }, + ], + title: "Edit text vs change to heading", + category: "Type changes", + description: "A edits the text while B promotes the block to a heading.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello universe", + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + }, + { + kind: "concurrent", + id: "concurrent-table-row-and-column", + title: "Add row vs add column", + category: "Tables", + description: "A adds a row while B adds a column.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-addcol-vs-addrow", + title: "Add column vs add row", + category: "Tables", + description: "A adds a column while B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + 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, + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { type: "tableContent", rows: [{ cells: ["A1", "B1"] }] }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + + { + kind: "concurrent", + id: "concurrent-table-delcol-vs-addrow", + title: "Delete column vs add row", + feedback: [ + { + severity: "high", + note: "Diff seems weird and A2 in wrong place", + }, + ], + category: "Tables", + description: "A deletes a column while B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1"] }, { cells: ["A2"] }], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-seq-col-then-row", + title: "A adds column then row, B adds column", + category: "Tables", + description: "A adds a column and then a row (two edits); B adds a column.", + initial: [TABLE_2X2], + applyA: (editor) => { + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }); + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1", "C1"] }, + { cells: ["A2", "B2", "C2"] }, + { cells: ["A3", "B3", "C3"] }, + ], + }, + }); + }, + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "D1"] }, { cells: ["A2", "B2", "D2"] }], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-seq-row-then-col", + title: "A adds row then column, B adds row", + category: "Tables", + description: "A adds a row and then a column (two edits); B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => { + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }); + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1", "C1"] }, + { cells: ["A2", "B2", "C2"] }, + { cells: ["A3", "B3", "C3"] }, + ], + }, + }); + }, + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["D1", "D2"] }, + ], + }, + }), + }, + + // --- Links --- + { + kind: "single", + id: "add-link", + title: "Add a link", + category: "Links", + description: "Turn part of a paragraph into a link.", + initial: [{ id: "p", type: "paragraph", content: "Visit the site" }], + apply: (editor) => + editor.updateBlock("p", { + content: [ + { type: "text", text: "Visit ", styles: {} }, + { type: "link", href: "https://example.com", content: "the site" }, + ], + }), + feedback: [ + { + severity: "low", + note: "Hover needs to say link has been edited instead if 'create link'", + }, + ], + }, + { + kind: "single", + id: "edit-link", + title: "Edit a link", + category: "Links", + description: "Change a link's URL and its text.", + initial: [ + { + id: "p", + type: "paragraph", + content: [ + { type: "text", text: "Visit ", styles: {} }, + { + type: "link", + href: "https://old.example.com", + content: "the old site", + }, + ], + }, + ], + apply: (editor) => + editor.updateBlock("p", { + content: [ + { type: "text", text: "Visit ", styles: {} }, + { + type: "link", + href: "https://new.example.com", + content: "the new site", + }, + ], + }), + feedback: [ + { + severity: "low", + note: "Hover needs to say link has been edited instead if 'create link'", + }, + ], + }, + { + kind: "single", + id: "remove-link", + title: "Remove a link", + category: "Links", + description: "Unlink a link, keeping its text.", + initial: [ + { + id: "p", + type: "paragraph", + content: [ + { type: "text", text: "Visit ", styles: {} }, + { type: "link", href: "https://example.com", content: "the site" }, + ], + }, + ], + apply: (editor) => + editor.updateBlock("p", { + content: [{ type: "text", text: "Visit the site", styles: {} }], + }), + feedback: [ + { + severity: "low", + note: "Hover needs to say link has been edited instead if 'create link'", + }, + ], + }, + + // --- Add / remove blocks: divider + empty blocks --- + + { + kind: "single", + id: "add-empty-block", + title: "Add an empty block", + category: "Add / remove blocks", + description: "Insert an empty paragraph after a block.", + initial: [{ id: "p", type: "paragraph", content: "A paragraph" }], + apply: (editor) => + editor.insertBlocks([{ type: "paragraph" }], "p", "after"), + feedback: [ + { + severity: "low", + note: "TBD: determine visual indicator on empty block", + }, + ], + }, + { + kind: "single", + id: "delete-one-empty", + title: "Delete one of two empty blocks", + category: "Add / remove blocks", + description: "Two empty paragraphs — delete one.", + initial: [ + { id: "e1", type: "paragraph" }, + { id: "e2", type: "paragraph" }, + ], + apply: (editor) => editor.removeBlocks(["e2"]), + feedback: [ + { + severity: "low", + note: "TBD: determine visual indicator on empty block", + }, + ], + }, + + // --- Multi-column --- + { + kind: "single", + id: "create-2-columns", + title: "Create two columns", + category: "Multi-column", + description: "Insert a two-column layout after a paragraph.", + initial: [{ id: "intro", type: "paragraph", content: "Intro paragraph" }], + apply: (editor) => + editor.insertBlocks( + [ + { + type: "columnList", + children: [ + { + type: "column", + children: [{ type: "paragraph", content: "Left column" }], + }, + { + type: "column", + children: [{ type: "paragraph", content: "Right column" }], + }, + ], + }, + ], + "intro", + "after", + ), + }, + { + kind: "single", + id: "remove-1-column", + title: "Remove a column", + category: "Multi-column", + description: "A two-column layout loses one of its columns.", + initial: [ + { + id: "cols", + type: "columnList", + children: [ + { + id: "col-left", + type: "column", + children: [{ type: "paragraph", content: "Left column" }], + }, + { + id: "col-right", + type: "column", + children: [{ type: "paragraph", content: "Right column" }], + }, + ], + }, + ], + apply: (editor) => editor.removeBlocks(["col-right"]), + }, + { + kind: "single", + id: "remove-middle-column", + title: "Remove a middle column", + category: "Multi-column", + description: "A three-column layout loses one of its columns.", + initial: [ + { + id: "cols", + type: "columnList", + children: [ + { + id: "col-left", + type: "column", + children: [{ type: "paragraph", content: "Left column" }], + }, + { + id: "col-middle", + type: "column", + children: [{ type: "paragraph", content: "Middle column" }], + }, + { + id: "col-right", + type: "column", + children: [{ type: "paragraph", content: "Right column" }], + }, + ], + }, + ], + apply: (editor) => editor.removeBlocks(["col-right"]), + }, + { + kind: "single", + id: "add-block-to-column", + title: "Add a block to a column", + category: "Multi-column", + description: "Insert a paragraph inside one column of a two-column layout.", + initial: [ + { + id: "cols", + type: "columnList", + children: [ + { + id: "col-left", + type: "column", + children: [ + { id: "left-p", type: "paragraph", content: "Left column" }, + ], + }, + { + id: "col-right", + type: "column", + children: [{ type: "paragraph", content: "Right column" }], + }, + ], + }, + ], + apply: (editor) => + editor.insertBlocks( + [{ type: "paragraph", content: "Added to the left column" }], + "left-p", + "after", + ), + }, + + // --- Large diffs (the shared testDocument — every block type at once) --- + { + kind: "single", + id: "large-diff-add-all", + title: "Add a whole document", + category: "Large diffs", + description: + "Insert every block type from the shared test document at once — a stress test for large diffs.", + initial: [{ id: "anchor", type: "paragraph", content: "Document start" }], + apply: (editor) => + editor.insertBlocks( + testDocument as unknown as GalleryPartialBlock[], + "anchor", + "after", + ), + feedback: [ + { + severity: "high", + note: "formatting changes should not show up in the diff, the diff should indicate content has been added", + }, + ], + }, + { + kind: "single", + id: "large-diff-delete-all", + title: "Delete a whole document", + 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[], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "(all content removed)" }, + ]), + feedback: [ + { + severity: "high", + note: "the 'all content removed' paragraph should show up below or above the document, not inside it", + }, + ], + }, + + // --- Merge / split --- + { + kind: "concurrent", + id: "concurrent-merge-vs-edit", + title: "Merge blocks vs edit block B", + category: "Merge / split", + description: + "User A merges block B into A (Backspace at the start of B); User B edits block B's text.", + initial: [ + { id: "a", type: "paragraph", content: "First" }, + { id: "b", type: "paragraph", content: "Second" }, + ], + applyA: (editor) => { + editor.setTextCursorPosition("b", "start"); + pressKey(editor, "Backspace"); + }, + applyB: (editor) => editor.updateBlock("b", { content: "Second (edited)" }), + feedback: [ + { + severity: "low", + note: "Content of User B is lost. This is not a regression related to diffs, but a known issue that can only be solved with a flat document model", + }, + ], + }, + { + kind: "concurrent", + id: "concurrent-split-vs-type", + title: "Split a block vs type at end", + category: "Merge / split", + description: + "User B splits the block in the middle (Enter); User A types at the end. Known not to work yet — needs a flat document model.", + initial: [{ id: "p", type: "paragraph", content: "Hello world" }], + applyB: (editor) => { + editor._tiptapEditor.commands.setTextSelection( + posBeforeText(editor, "world"), + ); + pressKey(editor, "Enter"); + }, + applyA: (editor) => editor.updateBlock("p", { content: "Hello world!" }), + feedback: [ + { + severity: "low", + note: "Broken merge: when one user splits a block while another edits it, the two edits can't be reconciled yet. A flat document model would be needed to resolve it.", + }, + ], + }, +]; diff --git a/examples/07-collaboration/14-suggestion-gallery/src/style.css b/examples/07-collaboration/14-suggestion-gallery/src/style.css new file mode 100644 index 0000000000..68dae69154 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/style.css @@ -0,0 +1,203 @@ +.bn-gallery { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; + height: 100vh; + box-sizing: border-box; + padding: 16px; +} + +.bn-gallery-sidebar { + overflow-y: auto; + border-right: 1px solid #e6e6e6; + padding-right: 12px; +} + +.bn-gallery-sidebar h2 { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #888; + margin: 0 0 12px; +} + +.bn-gallery-category { + margin-bottom: 16px; +} + +.bn-gallery-category-label { + font-size: 12px; + font-weight: 600; + color: #aaa; + margin-bottom: 4px; +} + +.bn-gallery-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 14px; + color: #333; +} + +.bn-gallery-item:hover { + background: #f2f2f2; +} + +.bn-gallery-item--active { + background: #e7f1ff; + color: #1971c2; + font-weight: 600; +} + +.bn-gallery-main { + overflow-y: auto; +} + +.bn-gallery-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} + +.bn-gallery-modes { + display: inline-flex; + border: 1px solid #d8d8d8; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; +} + +.bn-gallery-mode { + padding: 6px 14px; + border: none; + background: #fff; + cursor: pointer; + font-size: 14px; + color: #555; +} + +.bn-gallery-mode + .bn-gallery-mode { + border-left: 1px solid #d8d8d8; +} + +.bn-gallery-mode--active { + background: #1971c2; + color: #fff; + 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; + margin: 0 0 16px; + max-width: 60ch; +} + +.bn-gallery-editors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.bn-gallery-pane { + border: 1px solid #e6e6e6; + border-radius: 8px; + padding: 8px; + min-width: 0; +} + +.bn-gallery-pane-label { + font-size: 12px; + font-weight: 600; + color: #888; + padding: 4px 8px; +} + +.bn-gallery-feedback { + border: 1px solid #ececec; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 16px; + background: #fafafa; +} + +.bn-gallery-feedback-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #888; + margin-bottom: 6px; +} + +.bn-gallery-feedback-item { + display: flex; + gap: 8px; + align-items: baseline; + font-size: 13px; + line-height: 1.45; + color: #444; + padding: 5px 0 5px 8px; + border-left: 3px solid transparent; +} + +.bn-gallery-feedback-item + .bn-gallery-feedback-item { + border-top: 1px solid #efefef; +} + +.bn-gallery-feedback-item--high { + border-left-color: #e03131; +} + +.bn-gallery-feedback-item--low { + border-left-color: #f2b705; +} + +.bn-gallery-feedback-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 1px 6px; + border-radius: 4px; +} + +.bn-gallery-feedback-item--high .bn-gallery-feedback-badge { + background: #ffe3e3; + color: #c92a2a; +} + +.bn-gallery-feedback-item--low .bn-gallery-feedback-badge { + background: #fff3bf; + color: #a67c00; +} + +.bn-gallery-feedback-item--info { + border-left-color: #1971c2; +} + +.bn-gallery-feedback-item--info .bn-gallery-feedback-badge { + background: #e7f1ff; + color: #1971c2; +} diff --git a/examples/07-collaboration/14-suggestion-gallery/tsconfig.json b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts b/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/08-extensions/02-versioning/.bnexample.json b/examples/08-extensions/02-versioning/.bnexample.json new file mode 100644 index 0000000000..cdeda1bf24 --- /dev/null +++ b/examples/08-extensions/02-versioning/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Extension"], + "dependencies": { + "@y/y": "^14.0.0-rc.17", + "@y/prosemirror": "^2.0.0-4" + } +} diff --git a/examples/08-extensions/02-versioning/README.md b/examples/08-extensions/02-versioning/README.md new file mode 100644 index 0000000000..7d018afd9b --- /dev/null +++ b/examples/08-extensions/02-versioning/README.md @@ -0,0 +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. + +**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. diff --git a/examples/08-extensions/02-versioning/index.html b/examples/08-extensions/02-versioning/index.html new file mode 100644 index 0000000000..19166360ab --- /dev/null +++ b/examples/08-extensions/02-versioning/index.html @@ -0,0 +1,14 @@ + + + + + In-Memory Versioning + + + +
+ + + diff --git a/examples/08-extensions/02-versioning/main.tsx b/examples/08-extensions/02-versioning/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/08-extensions/02-versioning/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/08-extensions/02-versioning/package.json b/examples/08-extensions/02-versioning/package.json new file mode 100644 index 0000000000..60575b12bf --- /dev/null +++ b/examples/08-extensions/02-versioning/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-extensions-versioning", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "@y/y": "^14.0.0-rc.17", + "@y/prosemirror": "^2.0.0-4", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/08-extensions/02-versioning/src/App.tsx b/examples/08-extensions/02-versioning/src/App.tsx new file mode 100644 index 0000000000..ce6c388233 --- /dev/null +++ b/examples/08-extensions/02-versioning/src/App.tsx @@ -0,0 +1,88 @@ +import "@blocknote/core/fonts/inter.css"; +import { + VersioningExtension, + createInMemoryVersioningAdapter, +} from "@blocknote/core/extensions"; +import { DiffVersioningExtension } from "@blocknote/core/y"; +import { + BlockNoteViewEditor, + useCreateBlockNote, + useExtensionState, + VersioningSidebar, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useState } from "react"; + +import "./style.css"; + +export default function App() { + // `createInMemoryVersioningAdapter` is passed as a factory function. The + // VersioningExtension will call it with the editor instance once it's ready. + 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.", + }, + ], + extensions: [ + VersioningExtension(createInMemoryVersioningAdapter), + // 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. + DiffVersioningExtension(), + ], + }); + + const { previewedSnapshotId } = useExtensionState(VersioningExtension, { + editor, + }); + + const [showSidebar, setShowSidebar] = useState(true); + + return ( +
+ +
+
+ + {!showSidebar && ( + + )} +
+ {showSidebar && ( +
+ setShowSidebar(false)} + /> +
+ )} +
+
+
+ ); +} diff --git a/examples/08-extensions/02-versioning/src/style.css b/examples/08-extensions/02-versioning/src/style.css new file mode 100644 index 0000000000..1b4f8812fb --- /dev/null +++ b/examples/08-extensions/02-versioning/src/style.css @@ -0,0 +1,71 @@ +/* App layout only. The versioning sidebar's own styling (header, snapshot + rows, selected/comparing states, the "..." menu) ships with the UI library + (@blocknote/mantine etc.), so it isn't repeated here. */ + +.wrapper { + 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 { + 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; + padding: 0; +} + +.editor-panel .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); + overflow: auto; + width: 350px; +} + +.dark .sidebar-section { + border-left-color: #2c2c2c; + box-shadow: -6px 0 16px rgba(0, 0, 0, 0.3); +} + +.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; +} diff --git a/examples/08-extensions/02-versioning/tsconfig.json b/examples/08-extensions/02-versioning/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/08-extensions/02-versioning/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/08-extensions/02-versioning/vite-env.d.ts b/examples/08-extensions/02-versioning/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/08-extensions/02-versioning/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/08-extensions/02-versioning/vite.config.ts b/examples/08-extensions/02-versioning/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/08-extensions/02-versioning/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/package.json b/package.json index 0f381d26cc..ab8a2eb8c6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "vite-plus": "catalog:", "wait-on": "9.0.5" }, - "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485", + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", "private": true, "scripts": { "dev": "vp run --filter @blocknote/example-editor dev", @@ -25,16 +25,17 @@ "deploy": "echo not working:(", "gen": "vp run --filter @blocknote/dev-scripts gen", "install-playwright": "cd tests && vp exec playwright install --with-deps", - "e2e:image": "docker build -t blocknote-e2e -f tests/Dockerfile .", + "e2e:image": "bash tests/docker-build.sh", "e2e": "bash tests/docker-run.sh -e CI=1 -- --run", "e2e:updateSnaps": "bash tests/docker-run.sh -e CI=1 -- --run -u", "e2e:report": "serve -l 4173 tests/playwright-report", - "lint": "vp lint", + "lint": "vp lint --type-aware", + "typecheck": "tsgo --noEmit -p tsconfig.json", "postpublish": "rm -rf packages/core/README.md && rm -rf packages/react/README.md", "prebuild": "cp README.md packages/core/README.md && cp README.md packages/react/README.md", "prestart": "vp run build", "start": "vp run --filter @blocknote/example-editor preview", - "test": "vp run -r test", + "test": "vp run --filter \"@blocknote/*\" --filter \"docs\" test", "format": "vp fmt", "prepare": "vp config" }, diff --git a/packages/ariakit/src/attributionMarks/AttributionTooltip.tsx b/packages/ariakit/src/attributionMarks/AttributionTooltip.tsx new file mode 100644 index 0000000000..d74fb2936d --- /dev/null +++ b/packages/ariakit/src/attributionMarks/AttributionTooltip.tsx @@ -0,0 +1,20 @@ +import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; + +type AttributionTooltipProps = ComponentProps["AttributionTooltip"]["Root"]; + +export const AttributionTooltip = (props: AttributionTooltipProps) => { + const { className, markClassName, backgroundColor, children, ...rest } = + props; + + assertEmpty(rest); + + return ( + + {children} + + ); +}; diff --git a/packages/ariakit/src/components.ts b/packages/ariakit/src/components.ts index ab70ebb21f..d97a129f3f 100644 --- a/packages/ariakit/src/components.ts +++ b/packages/ariakit/src/components.ts @@ -19,6 +19,7 @@ import { PanelTextInput } from "./panel/PanelTextInput.js"; import { Popover, PopoverContent, PopoverTrigger } from "./popover/Popover.js"; import { SideMenu } from "./sideMenu/SideMenu.js"; import { SideMenuButton } from "./sideMenu/SideMenuButton.js"; +import { AttributionTooltip } from "./attributionMarks/AttributionTooltip.js"; import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.js"; import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.js"; import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.js"; @@ -37,6 +38,10 @@ import { Card, CardSection, ExpandSectionsPrompt } from "./comments/Card.js"; import { Comment } from "./comments/Comment.js"; import { Editor } from "./comments/Editor.js"; import { Badge, BadgeGroup } from "./badge/Badge.js"; +import { + Sidebar as VersioningSidebar, + Snapshot as VersioningSnapshot, +} from "./versioning/Versioning.js"; export const components: Components = { FormattingToolbar: { @@ -73,6 +78,9 @@ export const components: Components = { Label: SuggestionMenuLabel, Loader: SuggestionMenuLoader, }, + AttributionTooltip: { + Root: AttributionTooltip, + }, TableHandle: { Root: TableHandle, ExtendButton: ExtendButton, @@ -84,6 +92,10 @@ export const components: Components = { CardSection: CardSection, ExpandSectionsPrompt: ExpandSectionsPrompt, }, + Versioning: { + Sidebar: VersioningSidebar, + Snapshot: VersioningSnapshot, + }, Generic: { Badge: { Root: Badge, diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 46917be46b..6c7e73cb09 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -433,3 +433,175 @@ .bn-ariakit .bn-thread.selected .bn-ak-expand-sections-prompt { color: var(--bn-colors-selected-text); } + +/* ---- Versioning sidebar -------------------------------------------------- */ + +.bn-versioning-sidebar { + flex: 1; + overflow: auto; + padding-inline: 16px; +} + +.bn-versioning-sidebar-header { + align-items: center; + display: flex; + justify-content: space-between; + padding-block: 16px 8px; +} + +.bn-versioning-sidebar-header-title { + align-items: center; + display: flex; + gap: 6px; +} + +.bn-versioning-sidebar-title { + color: var(--bn-colors-menu-text); + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.bn-versioning-sidebar-header-actions { + align-items: center; + display: flex; + gap: 4px; +} + +.bn-snapshot { + background-color: var(--bn-colors-menu-background); + border: 1px solid transparent; + border-radius: 8px; + color: var(--bn-colors-menu-text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 4px; + overflow: visible; + padding: 12px 14px; + position: relative; + transition: + background-color 0.12s ease, + border-color 0.12s ease; + width: 100%; +} + +.bn-snapshot:hover { + background-color: var(--bn-colors-hovered-background); +} + +.bn-snapshot-name { + background: transparent; + border: none; + color: inherit; + font-size: 14px; + font-weight: 700; + padding: 0; + width: 100%; +} + +.bn-snapshot-name:focus { + outline: none; +} + +.bn-snapshot-body { + display: flex; + flex-direction: column; + font-size: 13px; + gap: 2px; +} + +/* The timestamp reads as normal body text — not a muted gray. */ +.bn-snapshot-date { + color: var(--bn-colors-menu-text); + font-size: 13px; + line-height: 1.3; +} + +/* Authors / "restored from" are secondary, but still readable. */ +.bn-snapshot-original-date, +.bn-snapshot-secondary-label { + color: #6b7280; + font-size: 13px; + line-height: 1.3; +} + +.dark .bn-snapshot-original-date, +.dark .bn-snapshot-secondary-label { + color: #9ca3af; +} + +/* "..." trigger — hidden until the row is hovered or its menu is open. */ +.bn-snapshot .bn-snapshot-menu { + opacity: 0; + position: absolute; + right: 8px; + top: 8px; + transition: opacity 0.12s ease; +} + +.bn-snapshot:hover .bn-snapshot-menu, +.bn-snapshot:focus-within .bn-snapshot-menu { + opacity: 1; +} + +/* Strip the action-toolbar's box so the trigger is flat — just the icon. */ +.bn-versioning-sidebar .bn-snapshot-menu .bn-action-toolbar { + background-color: transparent; + border: none; + border-radius: 0; + padding: 0; +} + +.bn-versioning-sidebar .bn-snapshot .bn-snapshot-menu-trigger, +.bn-versioning-sidebar .bn-snapshot .bn-snapshot-menu-trigger:hover, +.bn-versioning-sidebar .bn-snapshot .bn-snapshot-menu-trigger[data-selected] { + background-color: transparent; + border: none; + height: auto; + min-width: 0; + padding: 2px; +} + +.bn-versioning-sidebar .bn-snapshot .bn-snapshot-menu-trigger:hover { + opacity: 0.6; +} + +/* Selected (currently viewed) — a distinct indigo with white text. */ +.bn-versioning-sidebar .bn-snapshot.selected { + background-color: #3e5de7; + color: #fff; +} + +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-name { + color: #fff; +} + +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-date, +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-original-date, +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-secondary-label { + color: rgba(255, 255, 255, 0.8); +} + +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-menu-trigger { + color: #fff; +} + +/* Comparing-to (the diff baseline) — a subtle tint of the selected indigo. */ +.bn-versioning-sidebar .bn-snapshot.comparing { + background-color: color-mix( + in srgb, + #3e5de7 8%, + var(--bn-colors-editor-background) + ); +} + +.bn-snapshot-comparing-to { + align-items: center; + color: #3e5de7; + display: flex; + font-size: 13px; + font-weight: 600; + gap: 4px; +} diff --git a/packages/ariakit/src/versioning/Versioning.tsx b/packages/ariakit/src/versioning/Versioning.tsx new file mode 100644 index 0000000000..54a5a01779 --- /dev/null +++ b/packages/ariakit/src/versioning/Versioning.tsx @@ -0,0 +1,60 @@ +import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const Sidebar = forwardRef< + HTMLDivElement, + ComponentProps["Versioning"]["Sidebar"] +>((props, ref) => { + const { className, children, ...rest } = props; + + assertEmpty(rest, false); + + return ( +
+ {children} +
+ ); +}); + +export const Snapshot = forwardRef< + HTMLDivElement, + ComponentProps["Versioning"]["Snapshot"] +>((props, ref) => { + const { + className, + selected, + comparing, + onClick, + actions, + children, + ...rest + } = props; + + assertEmpty(rest, false); + + return ( +
+ {children} + {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()} + > + {actions} +
+ )} +
+ ); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 72b58d02c3..e0bd0538f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -76,6 +76,11 @@ "types": "./types/src/yjs/index.d.ts", "import": "./dist/yjs.js", "require": "./dist/yjs.cjs" + }, + "./y": { + "types": "./types/src/y/index.d.ts", + "import": "./dist/y.js", + "require": "./dist/y.cjs" } }, "scripts": { @@ -104,7 +109,7 @@ "@tiptap/pm": "^3.13.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", - "lib0": "^0.2.99", + "lib0": "1.0.0-rc.14", "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", @@ -125,7 +130,10 @@ "peerDependencies": { "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "@y/y": "^14.0.0-rc.17", + "@y/prosemirror": "^2.0.0-4", + "@y/protocols": "^1.0.6-rc.1" }, "peerDependenciesMeta": { "y-prosemirror": { @@ -136,6 +144,15 @@ }, "yjs": { "optional": true + }, + "@y/y": { + "optional": true + }, + "@y/prosemirror": { + "optional": true + }, + "@y/protocols": { + "optional": true } }, "eslintConfig": { diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 25debee60c..b41b268617 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -49,7 +49,7 @@ export function insertBlocks< // Now that the `PartialBlock`s have been converted to nodes, we can // re-convert them into full `Block`s. const insertedBlocks = nodesToInsert.map((node) => - nodeToBlock(node, pmSchema), + nodeToBlock(node, tr.doc), ) as Block[]; return insertedBlocks; diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts index 2491616e29..ebe8ae9eff 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js"; +import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { getParentBlockInfo, mergeBlocksCommand } from "./mergeBlocks.js"; @@ -14,7 +14,7 @@ function mergeBlocks(posBetweenBlocks: number) { function getPosBeforeSelectedBlock() { return getEditor().transact( - (tr) => getBlockInfoFromTransaction(tr).bnBlock.beforePos, + (tr) => getBlockInfoFromSelection(tr).bnBlock.beforePos, ); } diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts index fec01f91e6..61964a49ee 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -3,8 +3,9 @@ import { CellSelection } from "prosemirror-tables"; import { describe, expect, it } from "vite-plus/test"; import { - getBlockInfoFromTransaction, - getNearestBlockPos, + getBlockInfoAtNearest, + getBlockInfoFromSelection, + getNodeId, } from "../../../getBlockInfoFromPos.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { @@ -16,9 +17,7 @@ import { const getEditor = setupTestEnv(); function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { - const blockInfo = getEditor().transact((tr) => - getBlockInfoFromTransaction(tr), - ); + const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr)); if (!blockInfo.isBlockContainer) { throw new Error( `Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`, @@ -222,13 +221,16 @@ describe("Test moveBlocksUp", () => { moveBlocksUp(getEditor(), "paragraph-2"); - const { anchor, head } = getEditor().transact((tr) => tr.selection); - const anchorBlockId = getEditor().transact( - (tr) => getNearestBlockPos(tr.doc, anchor).node.attrs.id, - ); - const headBlockId = getEditor().transact( - (tr) => getNearestBlockPos(tr.doc, head).node.attrs.id, - ); + const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({ + anchorBlockId: getNodeId( + getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node, + tr.doc, + ), + headBlockId: getNodeId( + getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node, + tr.doc, + ), + })); expect(anchorBlockId).toBe("paragraph-1"); expect(headBlockId).toBe("paragraph-1"); }); @@ -343,13 +345,16 @@ describe("Test moveBlocksDown", () => { moveBlocksDown(getEditor(), "paragraph-0"); - const { anchor, head } = getEditor().transact((tr) => tr.selection); - const anchorBlockId = getEditor().transact( - (tr) => getNearestBlockPos(tr.doc, anchor).node.attrs.id, - ); - const headBlockId = getEditor().transact( - (tr) => getNearestBlockPos(tr.doc, head).node.attrs.id, - ); + const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({ + anchorBlockId: getNodeId( + getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node, + tr.doc, + ), + headBlockId: getNodeId( + getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node, + tr.doc, + ), + })); expect(anchorBlockId).toBe("paragraph-1"); expect(headBlockId).toBe("paragraph-1"); }); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index bb2f08dfca..44d7f595d9 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -9,7 +9,10 @@ import { CellSelection } from "prosemirror-tables"; import { Block } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; -import { getNearestBlockPos } from "../../../getBlockInfoFromPos.js"; +import { + getBlockInfoAtNearest, + getNodeId, +} from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; type BlockSelectionData = ( @@ -44,31 +47,34 @@ function getBlockSelectionData( editor: BlockNoteEditor, ): BlockSelectionData { return editor.transact((tr) => { - const anchorBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.anchor); + const anchorBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.anchor); + + const anchorBlockId = getNodeId(anchorBlockPosInfo.bnBlock.node, tr.doc); if (tr.selection instanceof CellSelection) { return { type: "cell" as const, - anchorBlockId: anchorBlockPosInfo.node.attrs.id, + anchorBlockId, anchorCellOffset: - tr.selection.$anchorCell.pos - anchorBlockPosInfo.posBeforeNode, + tr.selection.$anchorCell.pos - anchorBlockPosInfo.bnBlock.beforePos, headCellOffset: - tr.selection.$headCell.pos - anchorBlockPosInfo.posBeforeNode, + tr.selection.$headCell.pos - anchorBlockPosInfo.bnBlock.beforePos, }; } else if (tr.selection instanceof NodeSelection) { return { type: "node" as const, - anchorBlockId: anchorBlockPosInfo.node.attrs.id, + anchorBlockId, }; } else { - const headBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.head); + const headBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.head); return { type: "text" as const, - anchorBlockId: anchorBlockPosInfo.node.attrs.id, - headBlockId: headBlockPosInfo.node.attrs.id, - anchorOffset: tr.selection.anchor - anchorBlockPosInfo.posBeforeNode, - headOffset: tr.selection.head - headBlockPosInfo.posBeforeNode, + anchorBlockId, + headBlockId: getNodeId(headBlockPosInfo.bnBlock.node, tr.doc), + anchorOffset: + tr.selection.anchor - anchorBlockPosInfo.bnBlock.beforePos, + headOffset: tr.selection.head - headBlockPosInfo.bnBlock.beforePos, }; } }); diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 1540bbed74..a0f76fdff0 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -3,7 +3,7 @@ import { Transaction } from "prosemirror-state"; import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; -import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js"; +import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; /** * Modified version of prosemirror-schema-list's sinkItem. @@ -193,7 +193,7 @@ export function unnestBlock(editor: BlockNoteEditor) { export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromTransaction(tr); + const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; }); @@ -201,7 +201,7 @@ export function canNestBlock(editor: BlockNoteEditor) { export function canUnnestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromTransaction(tr); + const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); return tr.doc.resolve(blockContainer.beforePos).depth > 1; }); diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index f1e946f909..b6a280b330 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -1,6 +1,7 @@ import { type Node } from "prosemirror-model"; import { type Transaction } from "prosemirror-state"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; +import { getNodeId } from "../../../getBlockInfoFromPos.js"; import type { BlockIdentifier, BlockSchema, @@ -54,18 +55,21 @@ export function removeAndInsertBlocks< } // Keeps traversing nodes if block with target ID has not been found. - if ( - !node.type.isInGroup("bnBlock") || - !idsOfBlocksToRemove.has(node.attrs.id) - ) { + if (!node.type.isInGroup("bnBlock")) { + return true; + } + + const nodeId = getNodeId(node, tr.doc); + + if (!idsOfBlocksToRemove.has(nodeId)) { return true; } // Saves the block that is being deleted. - removedBlocks.push(nodeToBlock(node, pmSchema)); - idsOfBlocksToRemove.delete(node.attrs.id); + removedBlocks.push(nodeToBlock(node, tr.doc)); + idsOfBlocksToRemove.delete(nodeId); - if (blocksToInsert.length > 0 && node.attrs.id === idOfFirstBlock) { + if (blocksToInsert.length > 0 && nodeId === idOfFirstBlock) { const oldDocSize = tr.doc.nodeSize; tr.insert(pos, nodesToInsert); const newDocSize = tr.doc.nodeSize; @@ -116,7 +120,7 @@ export function removeAndInsertBlocks< // Converts the nodes created from `blocksToInsert` into full `Block`s. const insertedBlocks = nodesToInsert.map((node) => - nodeToBlock(node, pmSchema), + nodeToBlock(node, tr.doc), ) as Block[]; return { insertedBlocks, removedBlocks }; diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index b4b4c05a04..ab02a865f0 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from "vite-plus/test"; import { getBlockInfo, - getBlockInfoFromTransaction, + getBlockInfoFromSelection, + getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; import { setupTestEnv } from "../../setupTestEnv.js"; @@ -137,12 +138,12 @@ describe("Test splitBlocks", () => { splitBlock(getEditor().transact((tr) => tr.selection.anchor)); - const bnBlock = getEditor().transact( - (tr) => getBlockInfoFromTransaction(tr).bnBlock, + const blockId = getEditor().transact((tr) => + getNodeId(getBlockInfoFromSelection(tr).bnBlock.node, tr.doc), ); const anchorIsAtStartOfNewBlock = - bnBlock.node.attrs.id === "0" && + blockId === "0" && getEditor().transact((tr) => tr.selection.$anchor.parentOffset) === 0; expect(anchorIsAtStartOfNewBlock).toBeTruthy(); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index 298ffc6f4c..57aaf34fdd 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import type { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { getBlockInfo } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; import { setupTestEnv } from "../../setupTestEnv.js"; @@ -576,3 +577,360 @@ describe("Test updateBlock", () => { expect(getEditor().document).toMatchSnapshot(); }); }); + +/** + * These tests assert that `updateBlock` produces the smallest possible set of + * ProseMirror steps, rather than replacing whole blocks/content/children when + * only a small part changed. + */ +describe("Test updateBlock minimal steps", () => { + // Runs `updateBlock` in a throwaway transaction and returns the resulting + // steps as JSON for inspection. + const getSteps = (blockId: string, update: PartialBlock) => { + let steps: any[] = []; + getEditor().transact((tr) => { + updateBlock(tr, blockId, update); + steps = tr.steps.map((s) => s.toJSON()); + }); + return steps; + }; + + it("Changing a single prop emits only attr steps", () => { + const steps = getSteps("heading-with-everything", { + props: { level: 3 }, + }); + + expect(steps).toEqual([ + { + stepType: "attr", + pos: expect.any(Number), + attr: "level", + value: 3, + }, + ]); + }); + + it("Changing only children does not touch content or container", () => { + const steps = getSteps("heading-with-everything", { + children: [ + { + id: "new-nested-paragraph", + type: "paragraph", + content: "New nested Paragraph 2", + }, + ], + }); + + // A single replace step covering the children range only. + expect(steps).toHaveLength(1); + expect(steps[0].stepType).toBe("replace"); + }); + + it("Appending a child is a pure insertion", () => { + const steps = getSteps("heading-with-everything", { + children: [ + // Existing child, kept as-is. + { + id: "nested-paragraph-1", + type: "paragraph", + content: "Nested Paragraph 1", + children: [ + { + id: "double-nested-paragraph-1", + type: "paragraph", + content: "Double Nested Paragraph 1", + }, + ], + }, + // New sibling. + { + id: "appended-child", + type: "paragraph", + content: "Appended", + }, + ], + }); + + expect(steps).toHaveLength(1); + expect(steps[0].stepType).toBe("replace"); + // Pure insertion: from === to (nothing deleted). + expect(steps[0].from).toBe(steps[0].to); + + expect( + (getEditor().getBlock("heading-with-everything") as any).children.map( + (c: any) => c.id, + ), + ).toEqual(["nested-paragraph-1", "appended-child"]); + }); + + it("Changing part of the content only replaces the changed range", () => { + // Original content: "Heading" + " with styled " + "content". + // Only the middle text changes. + const steps = getSteps("heading-with-everything", { + content: [ + { type: "text", text: "Heading", styles: { bold: true } }, + { type: "text", text: " with NEW ", styles: {} }, + { type: "text", text: "content", styles: { italic: true } }, + ], + }); + + expect(steps).toHaveLength(1); + const [step] = steps; + expect(step.stepType).toBe("replace"); + // The replaced range should be the diff ("styled" -> "NEW"), much smaller + // than the whole content node. + expect(step.to - step.from).toBeLessThan( + "Heading with styled content".length, + ); + // Only the changed text is inserted. + expect(step.slice.content[0].text).toBe("NEW"); + }); + + it("Changing one table cell only replaces that cell's text", () => { + const steps = getSteps("table-0", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2", "Cell 3"] }, + { cells: ["Cell 4", "CHANGED", "Cell 6"] }, + { cells: ["Cell 7", "Cell 8", "Cell 9"] }, + ], + }, + }); + + expect(steps).toHaveLength(1); + expect(steps[0].stepType).toBe("replace"); + + const block = getEditor().getBlock("table-0") as any; + expect(block.content.rows[1].cells[1].content[0].text).toBe("CHANGED"); + // Surrounding cells are untouched. + expect(block.content.rows[0].cells[0].content[0].text).toBe("Cell 1"); + expect(block.content.rows[2].cells[2].content[0].text).toBe("Cell 9"); + }); + + it("Updating with identical content/props/children emits no steps", () => { + const steps = getSteps("heading-with-everything", { + type: "heading", + props: { + backgroundColor: "red", + level: 2, + textAlignment: "center", + textColor: "red", + }, + content: [ + { type: "text", text: "Heading", styles: { bold: true } }, + { type: "text", text: " with styled ", styles: {} }, + { type: "text", text: "content", styles: { italic: true } }, + ], + }); + + expect(steps).toEqual([]); + }); + + it("Changing only a child's content replaces just that child's text", () => { + const steps = getSteps("paragraph-with-children", { + children: [ + { + id: "nested-paragraph-0", + type: "paragraph", + content: "CHANGED nested", + children: [ + { + id: "double-nested-paragraph-0", + type: "paragraph", + content: "Double Nested Paragraph 0", + }, + ], + }, + ], + }); + + // Diffed down to a single replace; the unchanged double-nested child and + // surrounding structure are left in place. + expect(steps).toHaveLength(1); + expect(steps[0].stepType).toBe("replace"); + + const block = getEditor().getBlock("paragraph-with-children") as any; + expect(block.children[0].content[0].text).toBe("CHANGED nested"); + expect(block.children[0].children[0].id).toBe("double-nested-paragraph-0"); + expect(block.children[0].children[0].content[0].text).toBe( + "Double Nested Paragraph 0", + ); + }); + + it("Removing a table row only replaces the removed range", () => { + const steps = getSteps("table-0", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2", "Cell 3"] }, + { cells: ["Cell 7", "Cell 8", "Cell 9"] }, + ], + }, + }); + + // Snapped to the removed (middle) row: a single replace where content is + // deleted (slice is empty -> from < to). + expect(steps).toHaveLength(1); + expect(steps[0].stepType).toBe("replace"); + expect(steps[0].to).toBeGreaterThan(steps[0].from); + + const block = getEditor().getBlock("table-0") as any; + expect(block.content.rows).toHaveLength(2); + expect(block.content.rows[0].cells[0].content[0].text).toBe("Cell 1"); + expect(block.content.rows[1].cells[2].content[0].text).toBe("Cell 9"); + }); + + // Regression: when the content node's TYPE changes, setNodeMarkupMinimal + // emits a ReplaceAroundStep. Mapping the original beforePos through that step + // with the default (forward) bias pushes the position past the node, so the + // subsequent content diff resolved into the wrong place and corrupted the doc. + // The position must be mapped with a -1 (before) bias. + it("Type change + content change keeps the document valid", () => { + const steps = getSteps("paragraph-3", { + type: "heading", + props: { level: 1 }, + content: "Now a heading", + }); + + // The type change requires a ReplaceAroundStep, but no error should be + // thrown and the resulting block must be correct. + expect(steps.length).toBeGreaterThan(0); + + const block = getEditor().getBlock("paragraph-3") as any; + expect(block.type).toBe("heading"); + expect(block.props.level).toBe(1); + expect(block.content[0].text).toBe("Now a heading"); + + // The editor document must still be internally consistent. + expect(() => getEditor()._tiptapEditor.state.doc.check()).not.toThrow(); + }); + + it("Type change followed by another update resolves positions correctly", () => { + const editor = getEditor(); + + // First: change the content-node type (forces a ReplaceAroundStep), which + // is the operation that previously left later positions stale. + editor.transact((tr) => { + updateBlock(tr, "paragraph-4", { + type: "heading", + props: { level: 2 }, + content: "Heading four", + }); + }); + + // Then: a follow-up update on a *later* block, whose position would be wrong + // if the first transaction had corrupted the doc length. + expect(() => { + editor.transact((tr) => { + updateBlock(tr, "paragraph-9", { + content: "Paragraph nine updated", + }); + }); + }).not.toThrow(); + + expect((editor.getBlock("paragraph-4") as any).type).toBe("heading"); + expect((editor.getBlock("paragraph-9") as any).content[0].text).toBe( + "Paragraph nine updated", + ); + expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); + }); + + // Strongest regression: a children change (adds a step BEFORE the content + // node is updated) combined with a content-node type change (ReplaceAroundStep) + // and a content change. This is the exact mix that left the original beforePos + // stale; without the -1 mapping bias the content diff resolves into the wrong + // place and corrupts/throws. + it("Children change + type change + content change stays valid", () => { + const editor = getEditor(); + + expect(() => { + editor.transact((tr) => { + updateBlock(tr, "paragraph-with-children", { + type: "heading", + props: { level: 2 }, + content: "Converted to heading", + children: [ + { + id: "nested-paragraph-0", + type: "paragraph", + content: "Updated nested", + }, + { + id: "brand-new-child", + type: "paragraph", + content: "Brand new child", + }, + ], + }); + }); + }).not.toThrow(); + + const block = editor.getBlock("paragraph-with-children") as any; + expect(block.type).toBe("heading"); + expect(block.props.level).toBe(2); + expect(block.content[0].text).toBe("Converted to heading"); + expect(block.children.map((c: any) => c.id)).toEqual([ + "nested-paragraph-0", + "brand-new-child", + ]); + expect(block.children[0].content[0].text).toBe("Updated nested"); + expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); + + // A follow-up update on a later block must still resolve correctly. + expect(() => { + editor.transact((tr) => { + updateBlock(tr, "paragraph-9", { content: "After conversion" }); + }); + }).not.toThrow(); + expect((editor.getBlock("paragraph-9") as any).content[0].text).toBe( + "After conversion", + ); + }); + + it("Type change with offset content replace stays minimal and valid", () => { + const editor = getEditor(); + const info = getBlockInfo( + getNodeById( + "paragraph-with-styled-content", + editor.prosemirrorState.doc, + )!, + ); + if (!info.isBlockContainer) { + throw new Error("paragraph-with-styled-content is not a block container"); + } + + // paragraph-with-styled-content is "Paragraph"(bold) + " with styled " + + // "content"(italic). Replace only the unstyled middle node while converting + // the block to a heading. The type change forces a ReplaceAroundStep, after + // which the offset-replace branch must still resolve the (now shifted) + // content position correctly. + let steps: any[] = []; + editor.transact((tr) => { + updateBlock( + tr, + "paragraph-with-styled-content", + { + type: "heading", + props: { level: 3 }, + content: [{ type: "text", text: " with NEW ", styles: {} }], + }, + info.blockContent.beforePos + 1 + "Paragraph".length, + info.blockContent.beforePos + 1 + "Paragraph with styled ".length, + ); + steps = tr.steps.map((s) => s.toJSON()); + }); + + expect(steps.length).toBeGreaterThan(0); + + const block = editor.getBlock("paragraph-with-styled-content") as any; + expect(block.type).toBe("heading"); + expect(block.props.level).toBe(3); + // The styled text on either side of the replaced range is preserved. + expect(block.content[0].text).toBe("Paragraph"); + expect(block.content[block.content.length - 1].text).toBe("content"); + expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index a3e2b3b0db..6ee03b04d9 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -127,7 +127,7 @@ export function updateBlockTr< // currently, we calculate the new node and replace the entire node with the desired new node. // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case - const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema); + const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc); const replacementNode = blockToNode( { children: existingBlock.children, // if no children are passed in, use existing children @@ -146,9 +146,10 @@ export function updateBlockTr< } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing - // attributes. - tr.setNodeMarkup(blockInfo.bnBlock.beforePos, newBnBlockNodeType, { - ...blockInfo.bnBlock.node.attrs, + + // attributes. Uses minimal steps so that an unchanged container (e.g. when + // only children or content changed) doesn't emit a step at all. + setNodeMarkupMinimal(tr, blockInfo.bnBlock.beforePos, newBnBlockNodeType, { ...block.props, }); @@ -219,29 +220,30 @@ function updateBlockContentNode< // Use either setNodeMarkup or replaceWith depending on whether the // content is being replaced or not. if (content === "keep") { - // use setNodeMarkup to only update the type and attributes - tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, { - ...blockInfo.blockContent.node.attrs, + // only update the type and attributes, keeping the content as-is + setNodeMarkupMinimal(tr, blockInfo.blockContent.beforePos, newNodeType, { ...block.props, }); } else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) { - // first update markup of the containing node - tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, { - ...blockInfo.blockContent.node.attrs, - ...block.props, - }); + // Update the markup of the containing node, then get its (possibly shifted) + // position back. + const contentBeforePos = setNodeMarkupMinimalAndRemap( + tr, + blockInfo.blockContent.beforePos, + newNodeType, + { ...block.props }, + ); - const start = - blockInfo.blockContent.beforePos + 1 + (replaceFromOffset ?? 0); + const start = contentBeforePos + 1 + (replaceFromOffset ?? 0); const end = - blockInfo.blockContent.beforePos + + contentBeforePos + 1 + (replaceToOffset ?? blockInfo.blockContent.node.content.size); // for content like table cells (where the blockcontent has nested PM nodes), // we need to figure out the correct openStart and openEnd for the slice when replacing - const contentDepth = tr.doc.resolve(blockInfo.blockContent.beforePos).depth; + const contentDepth = tr.doc.resolve(contentBeforePos).depth; const startDepth = tr.doc.resolve(start).depth; const endDepth = tr.doc.resolve(end).depth; @@ -254,10 +256,30 @@ function updateBlockContentNode< endDepth - contentDepth - 1, ), ); + } else if ( + newNodeType === oldNodeType || + newNodeType.validContent(blockInfo.blockContent.node.content) + ) { + // The new type can hold the existing content, so we can update the markup + // first and then diff the content. This keeps both steps minimal. + // + // First update the markup (type & attributes) of the content node using + // minimal steps (avoids replacing the whole node just to change attrs), then + // get its (possibly shifted) position back. + const contentBeforePos = setNodeMarkupMinimalAndRemap( + tr, + blockInfo.blockContent.beforePos, + newNodeType, + { ...block.props }, + ); + + // Then replace only the part of the content that actually changed, keeping + // any shared prefix/suffix untouched. + replaceContentMinimal(tr, contentBeforePos, Fragment.from(content)); } else { - // use replaceWith to replace the content and the block itself - // also reset the selection since replacing the block content - // sets it to the next block. + // The content type is incompatible with the new node type (e.g. switching + // between inline content, table content, and no content). We can't update + // the markup in-place, so replace the whole content node atomically. tr.replaceWith( blockInfo.blockContent.beforePos, blockInfo.blockContent.afterPos, @@ -272,6 +294,197 @@ function updateBlockContentNode< } } +/** + * Replaces the content of the node at `nodePos` with `newContent`, only + * touching the range that actually differs between the old and new content. + * + * - For textblocks (inline content), this diffs at the character level using + * `Fragment.findDiffStart`/`findDiffEnd`, so e.g. changing a single word only + * replaces that word rather than the entire paragraph. + * - For nested content (like tables or blockGroups), the diff is snapped to + * whole top-level children (rows / blocks), so only the changed children are + * replaced while unchanged leading/trailing children are left untouched. + */ +function replaceContentMinimal( + tr: Transform, + nodePos: number, + newContent: Fragment, +) { + const node = tr.doc.nodeAt(nodePos); + if (!node) { + throw new RangeError("No node at given position"); + } + + const oldContent = node.content; + // Position of the first child inside the node. + const contentStart = nodePos + 1; + + if (node.isTextblock) { + // Inline content: diff at the character/token level. A flat slice (no open + // depth) is valid because the children are inline leaves. + const diffStart = oldContent.findDiffStart(newContent); + if (diffStart === null) { + return; + } + + // `findDiffEnd` returns ends in TWO separate coordinate systems: `a` is an + // offset into the OLD content, `b` is an offset into the NEW content. They + // are NOT interchangeable when the two fragments differ in size. + const diffEnd = oldContent.findDiffEnd(newContent)!; + let { a: oldEnd, b: newEnd } = diffEnd; + + // The shared prefix (`diffStart`) and shared suffix can overlap, e.g. when + // inserting/deleting a run of text at a boundary. When that happens an end + // can fall before `diffStart`. Push the ends forward so neither precedes the + // shared prefix, keeping the OLD and NEW ranges aligned by the same amount + // (each coordinate system is checked independently, then the larger shift is + // applied to both so the suffix stays in sync). + const shift = Math.max(0, diffStart - oldEnd, diffStart - newEnd); + if (shift > 0) { + oldEnd += shift; + newEnd += shift; + } + + tr.replace( + // OLD-doc range to remove: uses old-content offsets. + contentStart + diffStart, + contentStart + oldEnd, + // Replacement: cut the NEW content using new-content offsets. `diffStart` + // is valid here because it lies within the shared prefix (identical in + // both fragments), and `newEnd` is a new-content offset. + new Slice(newContent.cut(diffStart, newEnd), 0, 0), + ); + return; + } + + // Nested/block content (table rows, child blocks, ...): snap the diff to whole + // top-level children so the replacement slice is always a valid sequence of + // complete children (open depth 0). + const oldChildCount = oldContent.childCount; + const newChildCount = newContent.childCount; + + // Number of identical leading children. + let startIndex = 0; + while ( + startIndex < oldChildCount && + startIndex < newChildCount && + oldContent.child(startIndex).eq(newContent.child(startIndex)) + ) { + startIndex++; + } + + // Number of identical trailing children (not overlapping the shared prefix). + let oldEndIndex = oldChildCount; + let newEndIndex = newChildCount; + while ( + oldEndIndex > startIndex && + newEndIndex > startIndex && + oldContent.child(oldEndIndex - 1).eq(newContent.child(newEndIndex - 1)) + ) { + oldEndIndex--; + newEndIndex--; + } + + // Nothing changed. + if (startIndex === oldEndIndex && startIndex === newEndIndex) { + return; + } + + // Convert child indices to document positions. + let from = contentStart; + for (let i = 0; i < startIndex; i++) { + from += oldContent.child(i).nodeSize; + } + let to = from; + for (let i = startIndex; i < oldEndIndex; i++) { + to += oldContent.child(i).nodeSize; + } + + // Collect the changed replacement children. + const replacement: PMNode[] = []; + for (let i = startIndex; i < newEndIndex; i++) { + replacement.push(newContent.child(i)); + } + + // Use a strict ReplaceStep (rather than the lenient `tr.replace`) so that + // invalid content for the parent node type (e.g. a columnList that would end + // up with non-column children) throws instead of being silently coerced. + tr.step( + new ReplaceStep(from, to, new Slice(Fragment.from(replacement), 0, 0)), + ); +} + +/** + * Updates the type and/or attributes of the node at `pos` using the smallest + * possible set of steps. + * + * - If neither the type nor any attribute changes, no step is emitted. + * - If only attributes change (type stays the same), an `AttrStep` is emitted + * for each changed attribute. These are minimal steps that don't touch the + * node's content. + * - If the type changes, `setNodeMarkup` is used, which keeps the node's content + * via a `ReplaceAroundStep`. + */ +function setNodeMarkupMinimal( + tr: Transform, + pos: number, + newType: NodeType, + newAttrs: Record, +) { + const node = tr.doc.nodeAt(pos); + if (!node) { + throw new RangeError("No node at given position"); + } + + // Only consider attributes that are actually valid for the target node type. + // `block.props` may contain props that belong to the content node but not the + // container (or vice versa), and these should be ignored here. + const validAttrs = newType.spec.attrs ?? {}; + const filteredNewAttrs: Record = {}; + for (const attr of Object.keys(newAttrs)) { + if (attr in validAttrs) { + filteredNewAttrs[attr] = newAttrs[attr]; + } + } + + const mergedAttrs = { ...node.attrs, ...filteredNewAttrs }; + + if (node.type === newType) { + // Only emit AttrSteps for attributes that actually changed. + for (const attr of Object.keys(mergedAttrs)) { + if (mergedAttrs[attr] !== node.attrs[attr]) { + tr.setNodeAttribute(pos, attr, mergedAttrs[attr]); + } + } + return; + } + + // Type changed - setNodeMarkup keeps the content via a ReplaceAroundStep. + tr.setNodeMarkup(pos, newType, mergedAttrs); +} + +/** + * Applies a minimal markup update to the node at `pos`, then returns `pos` + * re-mapped through only the steps that update added. + * + * `setNodeMarkupMinimal` may add a step (e.g. a ReplaceAroundStep when the node + * type changes), which leaves the original `pos` stale. Because `tr` may already + * contain steps from earlier ops (other `updateBlock` calls sharing the same + * transaction), we map through only the steps added here — not the whole + * `tr.mapping` — using a -1 (before) bias so the position stays anchored before + * the node rather than being pushed past it. + */ +function setNodeMarkupMinimalAndRemap( + tr: Transform, + pos: number, + newType: NodeType, + newAttrs: Record, +): number { + const baseMapLen = tr.mapping.maps.length; + setNodeMarkupMinimal(tr, pos, newType, newAttrs); + return tr.mapping.slice(baseMapLen).map(pos, -1); +} + function updateChildren< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -287,15 +500,13 @@ function updateChildren< // Checks if a blockGroup node already exists. if (blockInfo.childContainer) { - // Replaces all child nodes in the existing blockGroup with the ones created earlier. - - // use a replacestep to avoid the fitting algorithm - tr.step( - new ReplaceStep( - blockInfo.childContainer.beforePos + 1, - blockInfo.childContainer.afterPos - 1, - new Slice(Fragment.from(childNodes), 0, 0), - ), + // Replaces the child nodes in the existing blockGroup, only touching the + // range that actually changed (keeping unchanged leading/trailing + // children untouched). + replaceContentMinimal( + tr, + blockInfo.childContainer.beforePos, + Fragment.from(childNodes), ); } else { if (!blockInfo.isBlockContainer) { @@ -340,8 +551,7 @@ export function updateBlock< .resolve(posInfo.posBeforeNode + 1) // TODO: clean? .node(); - const pmSchema = getPmSchema(tr); - return nodeToBlock(blockContainerNode, pmSchema); + return nodeToBlock(blockContainerNode, tr.doc); } type CellAnchor = { row: number; col: number; offset: number }; diff --git a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index c018c907a5..1d87f58b49 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -8,7 +8,6 @@ import type { } from "../../../schema/index.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; -import { getPmSchema } from "../../pmUtil.js"; export function getBlock< BSchema extends BlockSchema, @@ -20,14 +19,13 @@ export function getBlock< ): Block | undefined { const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const pmSchema = getPmSchema(doc); const posInfo = getNodeById(id, doc); if (!posInfo) { return undefined; } - return nodeToBlock(posInfo.node, pmSchema); + return nodeToBlock(posInfo.node, doc); } export function getPrevBlock< @@ -42,7 +40,6 @@ export function getPrevBlock< typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; const posInfo = getNodeById(id, doc); - const pmSchema = getPmSchema(doc); if (!posInfo) { return undefined; } @@ -53,7 +50,7 @@ export function getPrevBlock< return undefined; } - return nodeToBlock(nodeToConvert, pmSchema); + return nodeToBlock(nodeToConvert, doc); } export function getNextBlock< @@ -67,7 +64,6 @@ export function getNextBlock< const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; const posInfo = getNodeById(id, doc); - const pmSchema = getPmSchema(doc); if (!posInfo) { return undefined; } @@ -80,7 +76,7 @@ export function getNextBlock< return undefined; } - return nodeToBlock(nodeToConvert, pmSchema); + return nodeToBlock(nodeToConvert, doc); } export function getParentBlock< @@ -93,7 +89,6 @@ export function getParentBlock< ): Block | undefined { const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const pmSchema = getPmSchema(doc); const posInfo = getNodeById(id, doc); if (!posInfo) { return undefined; @@ -112,5 +107,5 @@ export function getParentBlock< return undefined; } - return nodeToBlock(nodeToConvert, pmSchema); + return nodeToBlock(nodeToConvert, doc); } diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index fc166ea984..d6229a3f0a 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -22,7 +22,6 @@ export function getSelection< I extends InlineContentSchema, S extends StyleSchema, >(tr: Transaction): Selection | undefined { - const pmSchema = getPmSchema(tr); // Return undefined if the selection is collapsed or a node is selected. if (tr.selection.empty || "node" in tr.selection) { return undefined; @@ -51,7 +50,7 @@ export function getSelection< ); } - return nodeToBlock(node, pmSchema); + return nodeToBlock(node, tr.doc); }; const blocks: Block[] = []; @@ -92,7 +91,7 @@ export function getSelection< // [ id-2, id-3, id-4, id-6, id-7, id-8, id-9 ] if ($startBlockBeforePos.depth > sharedDepth) { // Adds the block that the selection starts in. - blocks.push(nodeToBlock($startBlockBeforePos.nodeAfter!, pmSchema)); + blocks.push(nodeToBlock($startBlockBeforePos.nodeAfter!, tr.doc)); // Traverses all depths from the depth of the block in which the selection // starts, up to the shared depth. @@ -224,8 +223,6 @@ export function setSelection( export function getSelectionCutBlocks(tr: Transaction, expandToWords = false) { // TODO: fix image node selection - const pmSchema = getPmSchema(tr); - const range = expandToWords ? expandPMRangeToWords(tr.doc, tr.selection) : tr.selection; @@ -258,7 +255,6 @@ export function getSelectionCutBlocks(tr: Transaction, expandToWords = false) { const selectionInfo = prosemirrorSliceToSlicedBlocks( tr.doc.slice(start.pos, end.pos, true), - pmSchema, ); return { diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index 83f5340698..5de7b6c20d 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -14,7 +14,8 @@ import type { import { UnreachableCaseError } from "../../../util/typescript.js"; import { getBlockInfo, - getBlockInfoFromTransaction, + getBlockInfoFromSelection, + getNodeId, } from "../../getBlockInfoFromPos.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; @@ -25,8 +26,7 @@ export function getTextCursorPosition< I extends InlineContentSchema, S extends StyleSchema, >(tr: Transaction): TextCursorPosition { - const { bnBlock } = getBlockInfoFromTransaction(tr); - const pmSchema = getPmSchema(tr.doc); + const { bnBlock } = getBlockInfoFromSelection(tr); const resolvedPos = tr.doc.resolve(bnBlock.beforePos); // Gets previous blockContainer node at the same nesting level, if the current node isn't the first child. @@ -47,11 +47,11 @@ export function getTextCursorPosition< } return { - block: nodeToBlock(bnBlock.node, pmSchema), - prevBlock: prevNode === null ? undefined : nodeToBlock(prevNode, pmSchema), - nextBlock: nextNode === null ? undefined : nodeToBlock(nextNode, pmSchema), + block: nodeToBlock(bnBlock.node, tr.doc), + prevBlock: prevNode === null ? undefined : nodeToBlock(prevNode, tr.doc), + nextBlock: nextNode === null ? undefined : nodeToBlock(nextNode, tr.doc), parentBlock: - parentNode === undefined ? undefined : nodeToBlock(parentNode, pmSchema), + parentNode === undefined ? undefined : nodeToBlock(parentNode, tr.doc), }; } @@ -113,6 +113,6 @@ export function setTextCursorPosition( ? info.childContainer.node.firstChild! : info.childContainer.node.lastChild!; - setTextCursorPosition(tr, child.attrs.id, placement); + setTextCursorPosition(tr, getNodeId(child, tr.doc), placement); } } diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index ced8f59b14..38c9921f18 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -5,7 +5,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; -import { getNearestBlockPos } from "../../getBlockInfoFromPos.js"; +import { getBlockInfoAtNearest, getNodeId } from "../../getBlockInfoFromPos.js"; import { acceptedMIMETypes } from "./acceptedMIMETypes.js"; function checkFileExtensionsMatch( @@ -159,16 +159,20 @@ export async function handleFileInsertion< } insertedBlockId = editor.transact((tr) => { - const posInfo = getNearestBlockPos(tr.doc, pos.pos); + const blockInfo = getBlockInfoAtNearest(tr, pos.pos); + const id = getNodeId(blockInfo.bnBlock.node, tr.doc); + // TODO technically data-id will always be the non-rewritten id, so there might be multiple in the document. + // getNodeId might find the wrong one (aka point to a deleted node when it should be a non-deleted on) + // This is acceptable right now, given that we don't expect edits on the document content const blockElement = editor.domElement?.querySelector( - `[data-id="${posInfo.node.attrs.id}"]`, + `[data-id="${id}"]`, ); const blockRect = blockElement?.getBoundingClientRect(); return insertOrUpdateBlock( editor, - editor.getBlock(posInfo.node.attrs.id)!, + editor.getBlock(id)!, fileBlock, blockRect && (blockRect.top + blockRect.bottom) / 2 > coords.top ? "before" diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts new file mode 100644 index 0000000000..6af6e7b6d8 --- /dev/null +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -0,0 +1,256 @@ +import { Schema } from "prosemirror-model"; +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { docToBlocks } from "./nodeConversions/nodeToBlock.js"; +import { getNodeId } from "./getBlockInfoFromPos.js"; +import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js"; + +/** + * Builds a `blockContainer` node holding a single paragraph with the given + * block `id`. When `suggestedDelete` is true, the container carries a + * `y-attributed-delete` mark, simulating a node that Yjs keeps in the document + * (in suggestion mode) after it has been deleted. + */ +function makeBlockContainer( + schema: Schema, + id: string, + text: string, + suggestedDelete: boolean, +) { + const paragraph = schema.nodes["paragraph"].createChecked( + {}, + text ? schema.text(text) : null, + ); + const marks = suggestedDelete + ? [schema.marks["y-attributed-delete"].create({ id: 1 })] + : undefined; + + return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); +} + +describe("getNodeId", () => { + let editor: BlockNoteEditor; + + // We only need the editor's ProseMirror schema to construct nodes, so a + // single non-mounted editor instance is enough for all cases here. + function getSchema() { + if (!editor) { + editor = BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); + } + return editor.pmSchema; + } + + it("returns the plain id for a normal block", () => { + const schema = getSchema(); + const block = makeBlockContainer(schema, "0", "Hello", false); + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, block), + ); + + // The only descendant blockContainer with id "0" is the one we built. + const blockContainer = doc.firstChild!.firstChild!; + + expect(getNodeId(blockContainer, doc)).toBe("0"); + }); + + it("throws when a node has no id", () => { + const schema = getSchema(); + // `create` (not `createChecked`) so we can omit the id attr default lying. + const block = schema.nodes["blockContainer"].create( + { id: null }, + schema.nodes["paragraph"].createChecked({}, schema.text("No id")), + ); + + expect(() => getNodeId(block, block)).toThrow(/does not have an ID/); + }); + + it("lies about the id of a suggested-deletion block to disambiguate duplicates", () => { + const schema = getSchema(); + + // First block: a "real" block with id "0". + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + // Second block: a suggested deletion that, in suggestion mode, shares the + // SAME id "0" as the live block but carries a y-attributed-delete mark. + const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]), + ); + + const blockGroup = doc.firstChild!; + const liveNode = blockGroup.child(0); + const deletedNode = blockGroup.child(1); + + // The live block keeps its plain id. + expect(getNodeId(liveNode, doc)).toBe("0"); + // The suggested-deletion block is disambiguated: it is preceded by one + // node with the same id, so its index is 1 -> "0-1". + expect(getNodeId(deletedNode, doc)).toBe("0-1"); + }); + + it("disambiguates multiple suggested-deletion blocks with the same id", () => { + const schema = getSchema(); + + // Three blocks all sharing id "0": one live block followed by two + // suggested deletions (e.g. the user deleted the same logical block twice + // across forks, all kept in the doc with the y-attributed-delete mark). + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true); + const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + liveBlock, + deletedBlock1, + deletedBlock2, + ]), + ); + + const blockGroup = doc.firstChild!; + + expect(getNodeId(blockGroup.child(0), doc)).toBe("0"); + // Preceded by 1 node with the same id. + expect(getNodeId(blockGroup.child(1), doc)).toBe("0-1"); + // Preceded by 2 nodes with the same id. + expect(getNodeId(blockGroup.child(2), doc)).toBe("0-2"); + }); + + it("counts only preceding same-id nodes, not unrelated blocks", () => { + const schema = getSchema(); + + // A block with a different id sits between the live and deleted blocks. + // It must NOT contribute to the suggested-deletion block's index. + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + const otherBlock = makeBlockContainer(schema, "1", "Other", false); + const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + liveBlock, + otherBlock, + deletedBlock, + ]), + ); + + const blockGroup = doc.firstChild!; + + expect(getNodeId(blockGroup.child(0), doc)).toBe("0"); + expect(getNodeId(blockGroup.child(1), doc)).toBe("1"); + // Only the single live block with id "0" precedes it -> index 1. + expect(getNodeId(blockGroup.child(2), doc)).toBe("0-1"); + }); + + it("throws when a suggested-deletion node is not found in the provided doc", () => { + const schema = getSchema(); + + // A suggested-deletion block that is NOT part of `doc` -> the walk never + // finds it, so getNodeId throws. + const orphanDeleted = makeBlockContainer(schema, "0", "Orphan", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked( + {}, + makeBlockContainer(schema, "0", "Live", false), + ), + ); + + expect(() => getNodeId(orphanDeleted, doc)).toThrow( + /not found in document/, + ); + }); +}); + +describe("docToBlocks round trip with suggested deletions", () => { + let editor: BlockNoteEditor; + + function getSchema() { + if (!editor) { + editor = BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); + } + return editor.pmSchema; + } + + it("reports distinct block ids even though two ProseMirror nodes share the same id", () => { + const schema = getSchema(); + + // A live block and a suggested-deletion block that, in suggestion mode, + // share the SAME ProseMirror id "0". + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]), + ); + + // At the ProseMirror level, both nodes share id "0". + const blockGroup = doc.firstChild!; + expect(blockGroup.child(0).attrs.id).toBe("0"); + expect(blockGroup.child(1).attrs.id).toBe("0"); + + // docToBlocks disambiguates them via getNodeId: the live block keeps "0", + // the suggested-deletion block becomes "0-1". + const blocks = docToBlocks(doc); + const ids = blocks.map((block) => block.id); + + expect(ids).toEqual(["0", "0-1"]); + // All block ids are distinct. + expect(new Set(ids).size).toBe(ids.length); + }); + + it("disambiguates multiple suggested-deletion blocks sharing an id in docToBlocks", () => { + const schema = getSchema(); + + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true); + const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + liveBlock, + deletedBlock1, + deletedBlock2, + ]), + ); + + const blocks = docToBlocks(doc); + const ids = blocks.map((block) => block.id); + + expect(ids).toEqual(["0", "0-1", "0-2"]); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("only disambiguates the suggested-deletion block, leaving unrelated ids intact", () => { + const schema = getSchema(); + + const liveBlock = makeBlockContainer(schema, "0", "Live", false); + const otherBlock = makeBlockContainer(schema, "1", "Other", false); + const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + liveBlock, + otherBlock, + deletedBlock, + ]), + ); + + const blocks = docToBlocks(doc); + const ids = blocks.map((block) => block.id); + + expect(ids).toEqual(["0", "1", "0-1"]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index e9c4228004..04ed789c98 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -44,6 +44,49 @@ export type BlockInfo = { } ); +export function isSuggestedDeletionNode(node: Node): boolean { + return node.marks.some((m) => ["y-attributed-delete"].includes(m.type.name)); +} + +export function getNodeId(node: Node, doc: Node): string { + const id = node.attrs.id; + if (!id) { + throw new Error(`Node ${node.type.name} does not have an ID`); + } + /** + * In suggestion mode, yjs will insert nodes which have actually been deleted but are kept in the document with a "y-attributed-delete" mark, + * and nodes which have been inserted but are not yet accepted by the user, with a "y-attributed-insert" mark. + * Both of these nodes will have the same ID as the original node, + * so we need to differentiate them by counting how many nodes with the same ID come before them in the document, and adding that count to the ID. + */ + if (isSuggestedDeletionNode(node)) { + // walk the doc to find the node and count it's index if others have the same ID, to differentiate them + let index = 0; + let found = false; + doc.descendants((descNode: Node) => { + if (found) { + return false; // stop the walk + } + if (descNode.attrs.id === id) { + if (descNode === node) { + found = true; + return false; // stop the walk + } + index++; + } + return true; // continue the walk + }); + if (!found) { + throw new Error( + `Node ${node.type.name} with ID ${id} not found in document`, + ); + } + return `${id}-${index}`; + } + // TODO handle deleted nodes + return id; +} + /** * Retrieves the position just before the nearest block node in a ProseMirror * doc, relative to a position. If the position is within a block node or its @@ -234,22 +277,15 @@ export function getBlockInfoFromResolvedPos(resolvedPos: ResolvedPos) { * Gets information regarding the ProseMirror nodes that make up a block. The * block chosen is the one currently containing the current ProseMirror * selection. - * @param state The ProseMirror editor state. + * @param source The ProseMirror editor state. */ -export function getBlockInfoFromSelection(state: EditorState) { - const posInfo = getNearestBlockPos(state.doc, state.selection.anchor); - - return getBlockInfo(posInfo); +export function getBlockInfoFromSelection(source: EditorState | Transaction) { + return getBlockInfoAtNearest(source, source.selection.anchor); } -/** - * Gets information regarding the ProseMirror nodes that make up a block. The - * block chosen is the one currently containing the current ProseMirror - * selection. - * @param tr The ProseMirror transaction. - */ -export function getBlockInfoFromTransaction(tr: Transaction) { - const posInfo = getNearestBlockPos(tr.doc, tr.selection.anchor); - - return getBlockInfo(posInfo); +export function getBlockInfoAtNearest( + source: EditorState | Transaction, + pos: number, +) { + return getBlockInfo(getNearestBlockPos(source.doc, pos)); } diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index c45af4cb71..94b2bc1d3b 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -11,9 +11,9 @@ import { import type { BlockSchema } from "../schema/index.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; +import { getNodeId } from "./getBlockInfoFromPos.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { isNodeBlock } from "./nodeUtil.js"; -import { getPmSchema } from "./pmUtil.js"; /** * Change detection utilities for BlockNote. @@ -40,7 +40,7 @@ function getParentBlockId(doc: Node, pos: number): string | undefined { for (let i = resolvedPos.depth; i > 0; i--) { const parent = resolvedPos.node(i); if (isNodeBlock(parent)) { - return parent.attrs.id; + return getNodeId(parent, doc); } } return undefined; @@ -161,7 +161,6 @@ function collectSnapshot< } > = {}; const childrenByParent: Record = {}; - const pmSchema = getPmSchema(doc); doc.descendants((node, pos) => { if (!isNodeBlock(node)) { return true; @@ -171,9 +170,10 @@ function collectSnapshot< if (!childrenByParent[key]) { childrenByParent[key] = []; } - const block = nodeToBlock(node, pmSchema); - byId[node.attrs.id] = { block, parentId }; - childrenByParent[key].push(node.attrs.id); + const block = nodeToBlock(node, doc); + const nodeId = getNodeId(node, doc); + byId[nodeId] = { block, parentId }; + childrenByParent[key].push(nodeId); return true; }); return { byId, childrenByParent }; diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 724b552bda..19f063d8bb 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -5,7 +5,6 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; -import { getPmSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; /** @@ -20,7 +19,6 @@ export function fragmentToBlocks< // pass these to the exporter const blocks: BlockNoDefaults[] = []; fragment.descendants((node) => { - const pmSchema = getPmSchema(node); if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { // selection started within a block group @@ -49,13 +47,13 @@ export function fragmentToBlocks< if (node.type.name === "columnList" && node.childCount === 1) { // column lists with a single column should be flattened (not the entire column list has been selected) node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, pmSchema)); + blocks.push(nodeToBlock(child, node)); }); return false; } if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, pmSchema)); + blocks.push(nodeToBlock(node, node)); // don't descend into children, as they're already included in the block returned by nodeToBlock return false; } diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 5048f91a2b..4ca1d9dbf5 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,4 +1,4 @@ -import { Mark, Node, Schema, Slice } from "@tiptap/pm/model"; +import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { @@ -18,12 +18,14 @@ import { isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; import { UnreachableCaseError } from "../../util/typescript.js"; -import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js"; +import { + getBlockInfoWithManualOffset, + getNodeId, +} from "../getBlockInfoFromPos.js"; import { getBlockCache, getBlockSchema, getInlineContentSchema, - getPmSchema, getStyleSchema, } from "../pmUtil.js"; @@ -385,21 +387,17 @@ export function nodeToCustomInlineContent< /** * Convert a Prosemirror node to a BlockNote block. - * - * TODO: test changes */ export function nodeToBlock< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, ->( - node: Node, - schema: Schema, - blockSchema: BSchema = getBlockSchema(schema) as BSchema, - inlineContentSchema: I = getInlineContentSchema(schema) as I, - styleSchema: S = getStyleSchema(schema) as S, - blockCache = getBlockCache(schema), -): Block { +>(node: Node, doc: Node): Block { + const schema = node.type.schema; + const blockSchema = getBlockSchema(schema) as BSchema; + const inlineContentSchema = getInlineContentSchema(schema) as I; + const styleSchema = getStyleSchema(schema) as S; + const blockCache = getBlockCache(schema); if (!node.type.isInGroup("bnBlock")) { throw Error("Node should be a bnBlock, but is instead: " + node.type.name); } @@ -412,10 +410,11 @@ export function nodeToBlock< const blockInfo = getBlockInfoWithManualOffset(node, 0); - let id = blockInfo.bnBlock.node.attrs.id; - - // Only used for blocks converted from other formats. - if (id === null) { + let id: string; + try { + id = getNodeId(blockInfo.bnBlock.node, doc); + } catch { + // Only used for blocks converted from other formats. id = UniqueID.options.generateID(); } @@ -444,16 +443,7 @@ export function nodeToBlock< const children: Block[] = []; blockInfo.childContainer?.node.forEach((child) => { - children.push( - nodeToBlock( - child, - schema, - blockSchema, - inlineContentSchema, - styleSchema, - blockCache, - ), - ); + children.push(nodeToBlock(child, doc)); }); let content: Block["content"]; @@ -502,27 +492,11 @@ export function docToBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, ->( - doc: Node, - schema: Schema = getPmSchema(doc), - blockSchema: BSchema = getBlockSchema(schema) as BSchema, - inlineContentSchema: I = getInlineContentSchema(schema) as I, - styleSchema: S = getStyleSchema(schema) as S, - blockCache = getBlockCache(schema), -) { +>(doc: Node) { const blocks: Block[] = []; if (doc.firstChild) { doc.firstChild.descendants((node) => { - blocks.push( - nodeToBlock( - node, - schema, - blockSchema, - inlineContentSchema, - styleSchema, - blockCache, - ), - ); + blocks.push(nodeToBlock(node, doc)); return false; }); } @@ -554,11 +528,6 @@ export function prosemirrorSliceToSlicedBlocks< S extends StyleSchema, >( slice: Slice, - schema: Schema, - blockSchema: BSchema = getBlockSchema(schema) as BSchema, - inlineContentSchema: I = getInlineContentSchema(schema) as I, - styleSchema: S = getStyleSchema(schema) as S, - blockCache: WeakMap> = getBlockCache(schema), ): { /** * The blocks that are included in the selection. @@ -629,14 +598,7 @@ export function prosemirrorSliceToSlicedBlocks< return; } - const block = nodeToBlock( - blockContainer, - schema, - blockSchema, - inlineContentSchema, - styleSchema, - blockCache, - ); + const block = nodeToBlock(blockContainer, slice.content.firstChild!); const childGroup = blockContainer.childCount > 1 ? blockContainer.child(1) : undefined; diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 3388c95413..248b7233f6 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,4 +1,5 @@ import type { Node } from "prosemirror-model"; +import { getNodeId } from "./getBlockInfoFromPos.js"; /** * Get a TipTap node by id @@ -17,7 +18,7 @@ export function getNodeById( } // Keeps traversing nodes if block with target ID has not been found. - if (!isNodeBlock(node) || node.attrs.id !== id) { + if (!isNodeBlock(node) || getNodeId(node, doc) !== id) { return true; } diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts index 16e03f883a..808dd20137 100644 --- a/packages/core/src/api/parsers/html/parseHTML.ts +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -30,7 +30,7 @@ export function HTMLToBlocks< const blocks: Block[] = []; for (let i = 0; i < parentNode.childCount; i++) { - blocks.push(nodeToBlock(parentNode.child(i), pmSchema)); + blocks.push(nodeToBlock(parentNode.child(i), parentNode)); } return blocks; diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts index eb71c2f7ab..0b33335788 100644 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts @@ -1,12 +1,12 @@ import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; export const handleEnter = (editor: BlockNoteEditor) => { const { blockInfo, selectionEmpty } = editor.transact((tr) => { return { - blockInfo: getBlockInfoFromTransaction(tr), + blockInfo: getBlockInfoFromSelection(tr), selectionEmpty: tr.selection.anchor === tr.selection.head, }; }); diff --git a/packages/core/src/blocks/Table/block.ts b/packages/core/src/blocks/Table/block.ts index b26bc31a9d..eb464373c0 100644 --- a/packages/core/src/blocks/Table/block.ts +++ b/packages/core/src/blocks/Table/block.ts @@ -11,6 +11,7 @@ import { } from "../../schema/index.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js"; @@ -39,6 +40,10 @@ const TiptapTableHeader = Node.create<{ */ content: "tableContent+", + marks() { + return suggestionMarks(this.editor); + }, + addAttributes() { return { colspan: { @@ -99,6 +104,10 @@ const TiptapTableCell = Node.create<{ content: "tableContent+", + marks() { + return suggestionMarks(this.editor); + }, + addAttributes() { return { colspan: { @@ -152,7 +161,9 @@ const TiptapTableNode = Node.create({ group: "blockContent", tableRole: "table", - marks: "deletion insertion modification", + marks() { + return suggestionMarks(this.editor); + }, isolating: true, parseHTML() { @@ -347,7 +358,9 @@ const TiptapTableRow = Node.create<{ content: "(tableCell | tableHeader)+", tableRole: "row", - marks: "deletion insertion modification", + marks() { + return suggestionMarks(this.editor); + }, parseHTML() { return [{ tag: "tr" }]; }, diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts index ceb383a611..755987449a 100644 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -1,6 +1,6 @@ import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; export const handleEnter = ( @@ -9,7 +9,7 @@ export const handleEnter = ( ) => { const { blockInfo, selectionEmpty } = editor.transact((tr) => { return { - blockInfo: getBlockInfoFromTransaction(tr), + blockInfo: getBlockInfoFromSelection(tr), selectionEmpty: tr.selection.anchor === tr.selection.head, }; }); diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index e930a9b4b3..5177f19195 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -7,12 +7,11 @@ import { ExtensionOptions, } from "../editor/BlockNoteExtension.js"; import { ShowSelectionExtension } from "../extensions/ShowSelection/ShowSelection.js"; +import { normalizeToUserStore, UserStoreOrResolver } from "../user/index.js"; import { CustomBlockNoteSchema } from "../schema/schema.js"; import { CommentMark } from "./mark.js"; import type { ThreadStore } from "./threadstore/ThreadStore.js"; import type { CommentBody, ThreadData } from "./types.js"; -import { User } from "./types.js"; -import { UserStore } from "./userstore/UserStore.js"; const PLUGIN_KEY = new PluginKey("blocknote-comments"); @@ -67,11 +66,16 @@ export const CommentsExtension = createExtension( */ threadStore: ThreadStore; /** - * Resolve user information for comments. + * Resolve user information (names, avatars) for comment authors. + * + * Either a resolver function (called with the ids of users that are not yet + * cached, returning their information) or a pre-built user store (see + * `createUserStore`). Pass the same store to the collaboration options so a + * single de-duped user cache is shared across comments and collaboration. * * See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info. */ - resolveUsers: (userIds: string[]) => Promise; + resolveUsers: UserStoreOrResolver; /** * A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor) */ @@ -87,9 +91,12 @@ export const CommentsExtension = createExtension( "threadStore is required to be defined when using comments", ); } + // Resolve users through this store, exposed on the extension instance so the + // comments UI can read from it directly. Accepts a resolver callback or a + // shared store (see the option docs above). + const userStore = normalizeToUserStore(resolveUsers); const markType = CommentMark.name; - const userStore = new UserStore(resolveUsers); const store = createStore( { pendingComment: false, @@ -157,6 +164,7 @@ export const CommentsExtension = createExtension( return { key: "comments", store, + userStore, runsBefore: ["link"], tiptapExtensions: [CommentMark], prosemirrorPlugins: [ @@ -362,7 +370,6 @@ export const CommentsExtension = createExtension( }); } }, - userStore, commentEditorSchema, } as const; }, diff --git a/packages/core/src/comments/types.ts b/packages/core/src/comments/types.ts index 38d3f5ac23..4ce11ea723 100644 --- a/packages/core/src/comments/types.ts +++ b/packages/core/src/comments/types.ts @@ -119,11 +119,3 @@ export type ThreadData = { */ deletedAt?: Date; }; -/** - * A collaborator of the document. - */ -export type User = { - id: string; - username: string; - avatarUrl: string; -}; diff --git a/packages/core/src/comments/userstore/UserStore.ts b/packages/core/src/comments/userstore/UserStore.ts deleted file mode 100644 index 7c48466ba6..0000000000 --- a/packages/core/src/comments/userstore/UserStore.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { User } from "../types.js"; -import { EventEmitter } from "../../util/EventEmitter.js"; - -/** - * The `UserStore` is used to retrieve and cache information about users. - * - * It does this by calling `resolveUsers` (which is user-defined in the Editor Options) - * for users that are not yet cached. - */ -export class UserStore extends EventEmitter { - private userCache: Map = new Map(); - - // avoid duplicate loads - private loadingUsers = new Set(); - - public constructor( - private readonly resolveUsers: (userIds: string[]) => Promise, - ) { - super(); - } - - /** - * Load information about users based on an array of user ids. - */ - public async loadUsers(userIds: string[]) { - const missingUsers = userIds.filter( - (id) => !this.userCache.has(id) && !this.loadingUsers.has(id), - ); - - if (missingUsers.length === 0) { - return; - } - - for (const id of missingUsers) { - this.loadingUsers.add(id); - } - - try { - const users = await this.resolveUsers(missingUsers); - for (const user of users) { - this.userCache.set(user.id, user); - } - this.emit("update", this.userCache); - } finally { - for (const id of missingUsers) { - // delete the users from the loading set - // on a next call to `loadUsers` we will either - // return the cached user or retry loading the user if the request failed failed - this.loadingUsers.delete(id); - } - } - } - - /** - * Retrieve information about a user based on their id, if cached. - * - * The user will have to be loaded via `loadUsers` first - */ - public getUser(userId: string): U | undefined { - return this.userCache.get(userId); - } - - /** - * Subscribe to changes in the user store. - * - * @param cb - The callback to call when the user store changes. - * @returns A function to unsubscribe from the user store. - */ - public subscribe(cb: (users: Map) => void): () => void { - return this.on("update", cb); - } -} diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 547e009d6f..29d31765a2 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -33,8 +33,8 @@ BASIC STYLES transition: all 0.2s; /* Workaround for selection issue on Chrome, see #1588 and also here: https://discuss.prosemirror.net/t/mouse-down-selection-behaviour-different-on-chrome/8426 - The :before element causes the selection to be set in the wrong place vs - other browsers. Setting no height fixes this, while list item indicators are + The :before element causes the selection to be set in the wrong place vs + other browsers. Setting no height fixes this, while list item indicators are still displayed fine as overflow is not hidden. */ height: 0; overflow: visible; @@ -183,6 +183,11 @@ NESTED BLOCKS > .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 + > :is(ins, del) + > .bn-suggestion-node > .bn-block-content[data-content-type="heading"] { font-size: var(--level); font-weight: bold; @@ -239,6 +244,11 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] + > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node > .bn-block-content[data-content-type="numberedListItem"]::before { content: var(--index) "."; } @@ -349,7 +359,12 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { + > .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: "•"; } @@ -740,3 +755,291 @@ NESTED BLOCKS .bn-thread-mark .bn-thread-mark-selected { background: rgba(255, 200, 0, 0.25); } + +div[data-type="modification"] { + display: inline; +} + +.bn-root ins, +.bn-root del { + background-color: color-mix(in srgb, var(--user-color-light) 50%, white); + color: var(--user-color-dark); + position: relative; + text-decoration: none; + border-radius: 4px; +} + +.dark.bn-root ins, +.dark.bn-root del { + background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + color: var(--user-color-light); +} + +/* +In the editor the / mark wrapper is rendered with `display: contents` +(see SuggestionMarks.ts) so it never affects layout (e.g. table cells). Because a +`display: contents` element paints nothing, the highlight is applied to the inner +content span instead, using the `--user-color-*` properties that cascade down +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 { + 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); + color: white; +} + +/* +Block-level (over a node) suggestion marks. The `.bn-suggestion-node` span is +`display: contents` so it can't paint a background itself; instead the wrapped +nodes (its children — e.g.

//) carry the highlight. Like inline marks +they also show an attribution tooltip on hover (handled in JS, see +SuggestionMarks.ts). + +This rule is the highlight for insertions, and the fallback for block deletions +that wrap content with no `.bn-block-content` of their own (e.g. a deleted table +row/cell). Block deletions that *do* wrap blocks are restyled per-block below. +*/ +.bn-suggestion-node > * { + background-color: color-mix(in srgb, var(--user-color-light) 50%, white); + border-radius: 4px; +} + +.dark.bn-root .bn-suggestion-node > * { + background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); +} + +/* +A deleted block is tagged with a localized "Deleted" badge before its content. +The wrapper span (.bn-suggestion-node--delete) is `display: contents` and can't +render a pseudo-element of its own, so the badge is rendered on the first wrapped +node, which inherits the label text from the `--deleted-label` custom property +set in SuggestionMarks.ts (falling back to "Deleted" if absent). This is the +fallback badge for deletions without a `.bn-block-content` (see above); deletions +that wrap blocks get a per-block badge below instead. +*/ +.bn-suggestion-node--delete > *:first-child::before { + content: var(--deleted-label, "Deleted"); + display: inline-block; + margin-right: 6px; + padding: 0 4px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + 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. */ + color: var(--bn-colors-editor-text); +} + +/* +Block suggestions are decided *per block*, not per mark: a deleted parent holding +a paragraph and an image should strike the paragraph text yet still flag the image +as deleted. So when a mark wraps real blocks we drop the subtree-wide highlight +(scoped with `:has(.bn-block-content)` so table row/cell suggestions keep the +fallback above) and restyle each `.bn-block-content` on its own terms below — for +insertions and deletions alike. +*/ +.bn-suggestion-node:has(.bn-block-content) > *, +.dark.bn-root .bn-suggestion-node:has(.bn-block-content) > * { + background-color: transparent; +} + +/* The fallback "Deleted" badge (above) is only for deletions with no + `.bn-block-content`; per-block deletions are flagged individually below. */ +.bn-suggestion-node--delete:has(.bn-block-content) > *:first-child::before { + content: none; +} + +/* +A block WITH inline content (paragraph, heading, list item, quote, code, …) needs +no block-level background: a deletion strikes its text through in the author's +color, while an insertion already highlights the text via its inline mark +(.bn-suggestion-mark), so the transparent rule above is all it needs. +`.bn-inline-content` is present only on inline-content blocks (table cells use a +bare

; images/files/dividers have none), so it's what tells the two cases +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 {
+  color: var(--user-color-dark);
+  text-decoration: line-through;
+}
+
+.dark.bn-root .bn-suggestion-node--delete .bn-block-content .bn-inline-content {
+  color: var(--user-color-light);
+}
+
+/*
+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. +*/ +.bn-suggestion-node--delete :is(td, th) p { + color: var(--user-color-dark); + text-decoration: line-through; +} + +.dark.bn-root .bn-suggestion-node--delete :is(td, th) p { + color: var(--user-color-light); +} + +.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before { + content: none; +} + +/* +A block with NO inline content (image, file, divider, whole table, …) has no text +to mark up, so the suggestion is shown as a filled card in the author's color, per +block — on insertions and deletions alike. The card lives on `.bn-block-content` +because it's the only element present for every block type (the suggestion wrapper +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. +*/ +.bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) { + background-color: color-mix(in srgb, var(--user-color-light) 50%, white); + border-radius: 16px; + padding: 12px; +} + +.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); +} + +/* +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 +gated on a wrapper that only width-bearing blocks have. +*/ +.bn-suggestion-node + .bn-block-content:not(:has(.bn-inline-content)):has( + > .bn-file-block-content-wrapper + ) { + width: fit-content; +} + +/* +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. +*/ +.bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) { + position: relative; + padding: 48px 24px 24px; +} + +.bn-suggestion-node--delete + .bn-block-content:not(:has(.bn-inline-content))::before { + content: var(--deleted-label, "Deleted"); + /* 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; + /* 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); +} + +/* +Modification marks (data-type="modification") are shown as a dotted underline in +the author's color rather than a filled highlight. The text and background are +left untouched so only the dotted underline carries the color. Both the inline +(.bn-suggestion-mark) and block-level (.bn-suggestion-node) variants are covered. +*/ +[data-type="modification"] .bn-suggestion-mark, +[data-type="modification"] .bn-suggestion-node > * { + background-color: transparent; + color: inherit; + text-decoration: underline dotted; + text-decoration-color: var(--user-color-dark); + text-decoration-thickness: 2px; + text-underline-offset: 2px; +} + +.dark.bn-root [data-type="modification"] .bn-suggestion-mark, +.dark.bn-root [data-type="modification"] .bn-suggestion-node > * { + background-color: transparent; + color: inherit; + text-decoration-color: var(--user-color-light); +} + +/* On hover, reveal the author's color as a filled background. */ +[data-type="modification"] .bn-suggestion-mark:hover, +[data-type="modification"] .bn-suggestion-node:hover > * { + background-color: color-mix(in srgb, var(--user-color-light) 50%, white); + border-radius: 4px; +} + +.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); +} + +/* +Deletions are shown as struck-through text in the author's color with no +background fill (unlike insertions, which carry a filled highlight). +*/ +.bn-suggestion-mark--delete { + background-color: transparent; + color: var(--user-color-dark); + text-decoration: line-through; +} + +.dark.bn-root .bn-suggestion-mark--delete { + background-color: transparent; + color: var(--user-color-light); +} + +/* +Attribution tooltip for suggestion marks ( / / modification). +Positioning and portaling are handled by the React controller via floating-ui +(see AttributionTooltipController), so only the static box styling lives +here. The background color is set inline from the mark's user color unless an +app-provided class overrides it (see getSuggestionMarkClassName). +*/ +.bn-suggestion-tooltip { + width: max-content; + max-width: calc(100vw - 8px); + padding: 0 4px; + font-weight: bold; + font-size: 12px; + color: white; + background-color: rgb(35, 35, 35); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + white-space: nowrap; + pointer-events: none; +} + +.bn-root del { + background-color: transparent; + color: var(--user-color-dark); + text-decoration: line-through; +} + +.dark.bn-root del { + background-color: transparent; + color: var(--user-color-light); +} + +.bn-root del:hover { + text-decoration-line: overline; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 13d65ad83d..0934271307 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -674,6 +674,14 @@ export class BlockNoteEditor< ...args: Parameters ) => this._extensionManager.registerExtension(...args) as any; + /** + * Atomically unregister old extensions and register new ones in a single + * plugin update, avoiding re-entrant dispatch issues. + */ + public replaceExtension: ExtensionManager["replaceExtension"] = ( + ...args: Parameters + ) => this._extensionManager.replaceExtension(...args); + /** * Get an extension from the editor */ diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index ea9d9a5680..f086444ecc 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -46,7 +46,7 @@ export class BlockManager< */ public get document(): Block[] { return this.editor.transact((tr) => { - return docToBlocks(tr.doc, this.editor.pmSchema); + return docToBlocks(tr.doc); }); } diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts new file mode 100644 index 0000000000..4c97e8849c --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -0,0 +1,249 @@ +/** + * @vitest-environment jsdom + */ +import { Plugin, PluginKey } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +function createMountedEditor( + extensions: BlockNoteEditor["options"]["extensions"], +) { + const editor = BlockNoteEditor.create({ extensions }); + editor.mount(document.createElement("div")); + return editor; +} + +/** + * Returns the index of the plugin identified by `key` within the editor's + * ProseMirror plugin list. A lower index means it runs/applies earlier. + */ +function pluginIndex( + editor: BlockNoteEditor, + key: PluginKey, +): number { + return editor.prosemirrorState.plugins.findIndex( + (plugin) => (plugin as any).spec?.key === key, + ); +} + +describe("ExtensionManager de-duplication by key", () => { + it("registers only the first extension when two share a key", () => { + let mountCount = 0; + + const first = createExtension(() => ({ + key: "dup", + value: "first", + mount() { + mountCount++; + return () => {}; + }, + })); + const second = createExtension(() => ({ + key: "dup", + value: "second", + mount() { + mountCount++; + return () => {}; + }, + })); + + const editor = createMountedEditor([first(), second()]); + + // The first registration wins. + expect(editor.getExtension(first)?.value).toBe("first"); + // The second registration was skipped entirely. + expect(editor.getExtension(second)).toBeUndefined(); + expect((editor.extensions.get("dup") as any)?.value).toBe("first"); + expect( + [...editor.extensions.values()].filter((e) => e.key === "dup").length, + ).toBe(1); + // Only the registered extension was mounted. + expect(mountCount).toBe(1); + }); + + it("does not re-register a dependency declared via blockNoteExtensions when it is already registered", () => { + // Two distinct factories sharing the key "dep". + const depDirect = createExtension(() => ({ + key: "dep", + value: "direct", + })); + const depFromParent = createExtension(() => ({ + key: "dep", + value: "from-parent", + })); + const parent = createExtension(() => ({ + key: "parent", + blockNoteExtensions: [depFromParent()], + })); + + // Register the dependency directly first, then a parent that also pulls in + // its own "dep" via blockNoteExtensions. + const editor = createMountedEditor([depDirect(), parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + // The directly-registered dependency wins; the one declared by the parent + // is skipped rather than overriding it. + expect(editor.getExtension(depDirect)?.value).toBe("direct"); + expect(editor.getExtension(depFromParent)).toBeUndefined(); + expect((editor.extensions.get("dep") as any)?.value).toBe("direct"); + }); + + it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => { + const dep = createExtension(() => ({ + key: "lonely-dep", + value: "dep", + })); + const parent = createExtension(() => ({ + key: "lonely-parent", + blockNoteExtensions: [dep()], + })); + + const editor = createMountedEditor([parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + expect(editor.getExtension(dep)?.value).toBe("dep"); + }); +}); + +describe("ExtensionManager ordering", () => { + it("orders an extension before another it declares in runsBefore", () => { + const firstKey = new PluginKey("rb-first"); + const secondKey = new PluginKey("rb-second"); + + const first = createExtension(() => ({ + key: "rb-first", + runsBefore: ["rb-second"], + prosemirrorPlugins: [new Plugin({ key: firstKey })], + })); + const second = createExtension(() => ({ + key: "rb-second", + prosemirrorPlugins: [new Plugin({ key: secondKey })], + })); + + // Register in the "wrong" order to prove runsBefore — not array order — + // determines precedence. + const editor = createMountedEditor([second(), first()]); + + expect(pluginIndex(editor, firstKey)).toBeLessThan( + pluginIndex(editor, secondKey), + ); + }); + + it("flattens sub-extensions and runs the parent after its blockNoteExtensions dependency", () => { + const subKey = new PluginKey("sub-order"); + const parentKey = new PluginKey("parent-order"); + + const sub = createExtension(() => ({ + key: "ordered-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "ordered-parent", + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent()]); + + // The sub-extension is flattened into the editor's extensions... + expect(editor.getExtension(sub)).toBeDefined(); + expect(editor.getExtension(parent)).toBeDefined(); + + // ...and because the parent declares the sub as a dependency, the sub runs + // before the parent (even though the parent is registered first). + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("forces a blockNoteExtensions dependency before a parent that has a higher base priority", () => { + // The parent declares `runsBefore` on an unrelated extension, which raises + // its priority above the default. Without an explicit dependency edge, the + // higher-priority parent would run before its sub. The dependency must + // override that so the sub still runs first. + const subKey = new PluginKey("forced-sub"); + const parentKey = new PluginKey("forced-parent"); + const otherKey = new PluginKey("forced-other"); + + const other = createExtension(() => ({ + key: "forced-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + const sub = createExtension(() => ({ + key: "forced-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "forced-parent", + runsBefore: ["forced-other"], + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent(), other()]); + + // The parent runs before the unrelated extension (its declared runsBefore)... + expect(pluginIndex(editor, parentKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but its dependency still runs before it. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("runs a shared sub-dependency before both extensions that declare it", () => { + const subKey = new PluginKey("shared-sub"); + const parentAKey = new PluginKey("shared-parent-a"); + const parentBKey = new PluginKey("shared-parent-b"); + const otherKey = new PluginKey("shared-other"); + + const other = createExtension(() => ({ + key: "shared-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + // A single sub-extension instance declared by two different parents. It is + // registered once (de-duplicated) and must run before both parents. + const sharedSub = createExtension(() => ({ + key: "shared-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parentA = createExtension(() => ({ + key: "shared-parent-a", + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentAKey })], + })); + // parentB declares the *already-registered* sub (so its registration is + // de-duplicated) and has a higher base priority via runsBefore. The + // dependency must still be recorded on the de-duplicated path so the sub + // runs before parentB too. + const parentB = createExtension(() => ({ + key: "shared-parent-b", + runsBefore: ["shared-other"], + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentBKey })], + })); + + const editor = createMountedEditor([parentA(), parentB(), other()]); + + // The sub is registered exactly once despite being declared twice. + expect( + [...editor.extensions.values()].filter((e) => e.key === "shared-sub") + .length, + ).toBe(1); + + // parentB's higher base priority puts it before the unrelated extension... + expect(pluginIndex(editor, parentBKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but the shared sub still runs before both parents. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentAKey), + ); + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentBKey), + ); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2bd6f0b34b..2592b25d2a 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -31,9 +31,6 @@ import { HardBreak, KeyboardShortcutsExtension, LinkExtension, - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, TextAlignmentExtension, TextColorExtension, UniqueID, @@ -70,9 +67,6 @@ export function getDefaultTiptapExtensions( Text, // marks: - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, ...(Object.values(editor.schema.styleSpecs).map((styleSpec) => { return styleSpec.implementation.mark.configure({ editor: editor, diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index c49f787f57..5cf6e74c1c 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -10,7 +10,10 @@ import { keymap } from "@tiptap/pm/keymap"; import { Plugin, TextSelection } from "prosemirror-state"; import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js"; -import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; +import { + getBlockInfoFromSelection, + getNodeId, +} from "../../../api/getBlockInfoFromPos.js"; import { sortByDependencies } from "../../../util/topo-sort.js"; import type { BlockNoteEditor, @@ -49,6 +52,12 @@ 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(); + /** + * 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 + * the extension that declares it, so it must run *before* its parent(s). + */ + private blockNoteExtensionDependents: Map> = new Map(); constructor( private editor: BlockNoteEditor, @@ -124,52 +133,7 @@ export class ExtensionManager { | ExtensionFactoryInstance | (Extension | ExtensionFactoryInstance)[], ): void { - const extensions = ([] as (Extension | ExtensionFactoryInstance)[]) - .concat(extension) - .filter(Boolean) as (Extension | ExtensionFactoryInstance)[]; - - if (!extensions.length) { - // eslint-disable-next-line no-console - console.warn(`No extensions found to register`, extension); - return; - } - - const registeredExtensions = extensions - .map((extension) => this.addExtension(extension)) - .filter(Boolean) as Extension[]; - - const pluginsToAdd = new Set(); - for (const extension of registeredExtensions) { - if (extension?.tiptapExtensions) { - // This is necessary because this can only switch out prosemirror plugins at runtime, - // it can't switch out Tiptap extensions since that can have more widespread effects (since a Tiptap extension can even add/remove to the schema). - - // eslint-disable-next-line no-console - console.warn( - `Extension ${extension.key} has tiptap extensions, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`, - extension, - ); - } - - if (extension?.inputRules?.length) { - // This is necessary because input rules are defined in a single prosemirror plugin which cannot be re-initialized. - // eslint-disable-next-line no-console - console.warn( - `Extension ${extension.key} has input rules, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`, - extension, - ); - } - - this.getProsemirrorPluginsFromExtension(extension).plugins.forEach( - (plugin) => { - pluginsToAdd.add(plugin); - }, - ); - } - - // TODO there isn't a great way to do sorting right now. This is something that should be improved in the future. - // So, we just append to the end of the list for now. - this.updatePlugins((plugins) => [...plugins, ...pluginsToAdd]); + this.replaceExtension(undefined, extension); } /** @@ -179,6 +143,12 @@ export class ExtensionManager { */ private addExtension( extension: Extension | ExtensionFactoryInstance, + /** + * When this extension is being added as a dependency declared in another + * extension's `blockNoteExtensions`, this is the key of that declaring + * (parent) extension. + */ + parentKey?: string, ): Extension | undefined { let instance: Extension; if (typeof extension === "function") { @@ -191,6 +161,29 @@ export class ExtensionManager { return undefined as any; } + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension that declares it. We record this dependency before the + // de-duplication check below, so that it applies even when multiple + // extensions declare the same sub-extension (and all but the first are + // de-duplicated). + if (parentKey) { + let dependents = this.blockNoteExtensionDependents.get(instance.key); + if (!dependents) { + dependents = new Set(); + this.blockNoteExtensionDependents.set(instance.key, dependents); + } + dependents.add(parentKey); + } + + // De-duplicate by key: if an extension with the same key is already + // registered, don't register it again. This allows an extension to declare + // a dependency on another extension via `blockNoteExtensions` without + // conflicting when the user (or another extension) registers that same + // extension directly. The first registration wins. + if (this.extensions.some((e) => e.key === instance.key)) { + return undefined as any; + } + // 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 ( @@ -205,8 +198,8 @@ export class ExtensionManager { this.extensions.push(instance); if (instance.blockNoteExtensions) { - for (const extension of instance.blockNoteExtensions) { - this.addExtension(extension); + for (const subExtension of instance.blockNoteExtensions) { + this.addExtension(subExtension, instance.key); } } @@ -260,17 +253,44 @@ export class ExtensionManager { | ExtensionFactory | (Extension | ExtensionFactory | string | undefined)[], ): void { - const extensions = this.resolveExtensions(toUnregister); + this.replaceExtension(toUnregister, []); + } - if (!extensions.length) { + /** + * 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 + * @returns void + */ + public replaceExtension( + toUnregister: + | undefined + | string + | Extension + | ExtensionFactory + | (Extension | ExtensionFactory | string | undefined)[], + toRegister: + | Extension + | ExtensionFactoryInstance + | (Extension | ExtensionFactoryInstance)[], + ): void { + // ---- Remove phase (no updatePlugins call) ---- + const extensionsToRemove = this.resolveExtensions(toUnregister); + + if (toUnregister && !extensionsToRemove.length) { // eslint-disable-next-line no-console console.warn(`No extensions found to unregister`, toUnregister); - return; } - let didWarn = false; - const pluginsToRemove = new Set(); - for (const extension of extensions) { + let didWarnUnregister = false; + // We collect both plugin references and plugin keys to remove. + // Key-based matching is needed because re-entrant dispatches (e.g. from + // y-prosemirror view hooks) can replace plugin instances in the ProseMirror + // state with new objects that share the same key, making reference-based + // matching unreliable. + const pluginRefsToRemove = new Set(); + const pluginKeysToRemove = new Set(); + for (const extension of extensionsToRemove) { this.extensions = this.extensions.filter((e) => e !== extension); this.extensionFactories.forEach((instance, factory) => { if (instance === extension) { @@ -282,12 +302,17 @@ export class ExtensionManager { const plugins = this.extensionPlugins.get(extension); plugins?.forEach((plugin) => { - pluginsToRemove.add(plugin); + pluginRefsToRemove.add(plugin); + const key = (plugin as any).spec?.key; + const keyStr = typeof key === "object" && key ? key.key : key; + if (typeof keyStr === "string") { + pluginKeysToRemove.add(keyStr); + } }); this.extensionPlugins.delete(extension); - if (extension.tiptapExtensions && !didWarn) { - didWarn = true; + if (extension.tiptapExtensions && !didWarnUnregister) { + didWarnUnregister = true; // eslint-disable-next-line no-console console.warn( `Extension ${extension.key} has tiptap extensions, but they will not be removed. Please separate the extension into multiple extensions if you want to remove them, or re-initialize the editor.`, @@ -296,9 +321,69 @@ export class ExtensionManager { } } - this.updatePlugins((plugins) => - plugins.filter((plugin) => !pluginsToRemove.has(plugin)), - ); + // ---- Add phase (no updatePlugins call) ---- + const newExtensions = ([] as (Extension | ExtensionFactoryInstance)[]) + .concat(toRegister) + .filter(Boolean) as (Extension | ExtensionFactoryInstance)[]; + + const registeredExtensions = newExtensions + .map((ext) => this.addExtension(ext)) + .filter(Boolean) as Extension[]; + + const pluginsToAdd: Plugin[] = []; + for (const extension of registeredExtensions) { + if (extension?.tiptapExtensions) { + // eslint-disable-next-line no-console + console.warn( + `Extension ${extension.key} has tiptap extensions, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`, + extension, + ); + } + + if (extension?.inputRules?.length) { + // eslint-disable-next-line no-console + console.warn( + `Extension ${extension.key} has input rules, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`, + extension, + ); + } + + this.getProsemirrorPluginsFromExtension(extension).plugins.forEach( + (plugin) => { + pluginsToAdd.push(plugin); + }, + ); + } + + // Nothing to do + if ( + !pluginRefsToRemove.size && + !pluginKeysToRemove.size && + !pluginsToAdd.length + ) { + return; + } + + // ---- 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, + ]); } /** @@ -326,7 +411,21 @@ export class ExtensionManager { this.options, ).filter((extension) => !this.disabledExtensions.has(extension.name)); - const getPriority = sortByDependencies(this.extensions); + const getPriority = sortByDependencies( + this.extensions.map((extension) => { + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension(s) that declared it, so we merge those parents into its + // `runsBefore`. + const dependents = this.blockNoteExtensionDependents.get(extension.key); + if (!dependents?.size) { + return extension; + } + return { + key: extension.key, + runsBefore: [...(extension.runsBefore ?? []), ...dependents], + }; + }), + ); const inputRulesByPriority = new Map(); for (const extension of this.extensions) { @@ -461,7 +560,7 @@ export class ExtensionManager { }); if (replaceWith) { const tr = state.tr; - const blockInfo = getBlockInfoFromTransaction(tr); + const blockInfo = getBlockInfoFromSelection(tr); if ( !blockInfo.isBlockContainer || @@ -477,10 +576,11 @@ export class ExtensionManager { // the new block when the content is replaced wholesale (e.g. // when the rule returns content: []). Move the cursor back // inside the new block so the user can keep typing. - const blockId = blockInfo.bnBlock.node.attrs.id; - if (blockId) { - setTextCursorPosition(tr, blockId, "start"); - } + setTextCursorPosition( + tr, + getNodeId(blockInfo.bnBlock.node, tr.doc), + "start", + ); return tr; } return null; diff --git a/packages/core/src/editor/performance.test.ts b/packages/core/src/editor/performance.test.ts index 5daf26fa84..74bde90473 100644 --- a/packages/core/src/editor/performance.test.ts +++ b/packages/core/src/editor/performance.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; @@ -6,6 +6,18 @@ import { BlockNoteEditor } from "./BlockNoteEditor.js"; * @vitest-environment jsdom */ +// Track editors created in each test so we can unmount them in afterEach — +// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that +// fires after vitest tears down jsdom, throwing +// `ReferenceError: document is not defined` and failing the run. +const activeEditors: BlockNoteEditor[] = []; + +afterEach(() => { + while (activeEditors.length) { + activeEditors.pop()!.unmount(); + } +}); + /** * Performance regression tests for issue #2595: * Typing/echo lag with many blocks (~50k chars total). @@ -25,6 +37,7 @@ function createEditorWithBlocks( ) { const editor = BlockNoteEditor.create(); editor.mount(document.createElement("div")); + activeEditors.push(editor); const blocks = []; for (let i = 0; i < blockCount; i++) { blocks.push({ diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts index f7860b523e..6fa413ab99 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; @@ -6,12 +6,25 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; * @vitest-environment jsdom */ +// Track editors created in each test so we can unmount them in afterEach — +// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that +// fires after vitest tears down jsdom, throwing +// `ReferenceError: document is not defined` and failing the run. +const activeEditors: BlockNoteEditor[] = []; + +afterEach(() => { + while (activeEditors.length) { + activeEditors.pop()!.unmount(); + } +}); + function createEditorWithBlocks( blockCount: number, blockType: "heading" | "paragraph" = "heading", ) { const editor = BlockNoteEditor.create(); editor.mount(document.createElement("div")); + activeEditors.push(editor); const blocks = []; for (let i = 0; i < blockCount; i++) { blocks.push({ diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts index 61ea522a82..1523ae5363 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts @@ -1,6 +1,7 @@ import { findChildrenInRange } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +import { getNodeId } from "../../api/getBlockInfoFromPos.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`previous-blocks`); @@ -93,7 +94,10 @@ export const PreviousBlockTypeExtension = createExtension(() => { (node) => node.attrs.id, ); const oldNodesById = new Map( - oldNodes.map((node) => [node.node.attrs.id, node]), + oldNodes.map((node) => [ + getNodeId(node.node, oldState.doc), + node, + ]), ); const newNodes = findChildrenInRange( newState.doc, @@ -102,7 +106,8 @@ export const PreviousBlockTypeExtension = createExtension(() => { ); for (const node of newNodes) { - const oldNode = oldNodesById.get(node.node.attrs.id); + const nodeId = getNodeId(node.node, newState.doc); + const oldNode = oldNodesById.get(nodeId); const oldContentNode = oldNode?.node.firstChild; const newContentNode = node.node.firstChild; @@ -122,11 +127,9 @@ export const PreviousBlockTypeExtension = createExtension(() => { depth: oldState.doc.resolve(oldNode.pos).depth, }; - currentTransactionOriginalOldBlockAttrs[node.node.attrs.id] = - oldAttrs; + currentTransactionOriginalOldBlockAttrs[nodeId] = oldAttrs; - prev.currentTransactionOldBlockAttrs[node.node.attrs.id] = - oldAttrs; + prev.currentTransactionOldBlockAttrs[nodeId] = oldAttrs; if ( oldAttrs.index !== newAttrs.index || @@ -137,7 +140,7 @@ export const PreviousBlockTypeExtension = createExtension(() => { (oldAttrs as any)["depth-change"] = oldAttrs.depth - newAttrs.depth; - prev.updatedBlocks.add(node.node.attrs.id); + prev.updatedBlocks.add(nodeId); } } } @@ -162,12 +165,13 @@ export const PreviousBlockTypeExtension = createExtension(() => { return; } - if (!pluginState.updatedBlocks.has(node.attrs.id)) { + const id = getNodeId(node, state.doc); + + if (!pluginState.updatedBlocks.has(id)) { return; } - const prevAttrs = - pluginState.currentTransactionOldBlockAttrs[node.attrs.id]; + const prevAttrs = pluginState.currentTransactionOldBlockAttrs[id]; const decorationAttrs: any = {}; for (const [nodeAttr, val] of Object.entries(prevAttrs)) { diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 530d6eb02b..4616d76b70 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -253,20 +253,22 @@ export class TableHandlesView implements PluginView { | BlockFromConfigNoChildren | undefined; - const pmNodeInfo = this.editor.transact((tr) => - getNodeById(blockEl.id, tr.doc), - ); + const { pmNodeInfo, doc } = this.editor.transact((tr) => ({ + pmNodeInfo: getNodeById(blockEl.id, tr.doc), + doc: tr.doc, + })); if (!pmNodeInfo) { throw new Error(`Block with ID ${blockEl.id} not found`); } const block = nodeToBlock( pmNodeInfo.node, - this.editor.pmSchema, - this.editor.schema.blockSchema, - this.editor.schema.inlineContentSchema, - this.editor.schema.styleSchema, - ); + doc, + ) as unknown as BlockFromConfigNoChildren< + DefaultBlockSchema["table"], + any, + any + >; if (editorHasBlockWithType(this.editor, "table")) { this.tablePos = pmNodeInfo.posBeforeNode + 1; diff --git a/packages/core/src/extensions/Versioning/Versioning.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts new file mode 100644 index 0000000000..158c152da4 --- /dev/null +++ b/packages/core/src/extensions/Versioning/Versioning.test.ts @@ -0,0 +1,435 @@ +/** + * @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 new file mode 100644 index 0000000000..cea8566ac5 --- /dev/null +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -0,0 +1,628 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + createExtension, + createStore, + type ExtensionOptions, +} from "../../editor/BlockNoteExtension.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, + }); + + 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) { + throw new Error( + "previewCurrentVersion requires `serializeCurrentContent` to be " + + "provided to the VersioningExtension options.", + ); + } + + 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 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(); + }, + // 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; + }, + 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); + + 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; + }, +); diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts new file mode 100644 index 0000000000..8d9c7567eb --- /dev/null +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts @@ -0,0 +1,469 @@ +/** + * @vitest-environment jsdom + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; +import { CURRENT_VERSION_ID, VersioningExtension } from "./Versioning.js"; +import { + createInMemoryPreviewController, + createInMemoryVersioningAdapter, + 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 }]); +} + +// --------------------------------------------------------------------------- +// 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(); + try { + const endpoints = createInMemoryVersioningEndpoints(); + + const s1 = await endpoints.create!([ + { + id: "1", + type: "paragraph" as const, + content: "v1" as any, + props: {} as any, + children: [], + }, + ]); + 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(); + } + }); + + 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: [], + }, + ], + { name: "old" }, + ); + + 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: [], + }, + ]); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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("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"); + }); +}); + +// --------------------------------------------------------------------------- +// 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, + ); + expect(afterRestore.length).toBe(3); + const backup = afterRestore.find( + (s) => s.restoredFromSnapshotId === snap1.id, + ); + 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", + ); + + // 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"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — diff delegation to the opt-in DiffVersioningExtension +// --------------------------------------------------------------------------- + +describe("in-memory versioning + DiffVersioningExtension", () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = BlockNoteEditor.create({ + extensions: [DiffVersioningExtension()], + }); + editor.mount(document.createElement("div")); + setEditorText(editor, "initial doc"); + }); + + afterEach(() => { + editor.unmount(); + }); + + const attributionMarkCount = () => { + let count = 0; + editor.prosemirrorState.doc.descendants((node) => { + count += node.marks.filter((m) => + m.type.name.startsWith("y-attributed-"), + ).length; + return true; + }); + 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 }); + + // The diff extension rendered attribution marks (initial vs changed doc). + expect(attributionMarkCount()).toBeGreaterThan(0); + + // Exiting the preview clears the marks and restores the live document. + ext.exitPreview(); + expect(attributionMarkCount()).toBe(0); + expect(getEditorText(editor)).toBe("changed doc"); + }); + + it("previewing without compareTo shows content with no diff marks", async () => { + const adapter = createInMemoryVersioningAdapter(editor); + const ext = VersioningExtension(adapter)({ editor }); + + const snap1 = await ext.create!({ name: "baseline" }); + setEditorText(editor, "changed doc"); + + await ext.previewSnapshot(snap1.id); + + expect(getEditorText(editor)).toBe("initial doc"); + expect(attributionMarkCount()).toBe(0); + }); +}); diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts new file mode 100644 index 0000000000..5c0491800f --- /dev/null +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts @@ -0,0 +1,263 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { Block } from "../../blocks/defaultBlocks.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; + + const replaceDoc = (blocks: Block[]) => { + editor.replaceBlocks(editor.document, blocks); + }; + + // 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"); + + 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; + } + + 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; + } + + // 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; + }, + }; +} + +// --------------------------------------------------------------------------- +// 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; + + return { + async list() { + return sortSnapshotsNewestFirst([...snapshots]); + }, + + async create(currentDoc, options) { + const now = Date.now(); + 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 = Date.now(); + 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 = Date.now(); + }, + + 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 (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(), + }; + return [current, ...(await endpoints.list())]; + }, + }, + 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 new file mode 100644 index 0000000000..c24920adc1 --- /dev/null +++ b/packages/core/src/extensions/Versioning/index.ts @@ -0,0 +1,2 @@ +export * from "./Versioning.js"; +export * from "./inMemoryVersioning.js"; diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index e568462a13..3258f127c2 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -18,3 +18,4 @@ export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; export * from "./SuggestionMenu/SuggestionMenu.js"; export * from "./TableHandles/TableHandles.js"; export * from "./TrailingNode/TrailingNode.js"; +export * from "./Versioning/index.js"; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts new file mode 100644 index 0000000000..dbb95c9a8c --- /dev/null +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts @@ -0,0 +1,187 @@ +/** + * @vitest-environment jsdom + */ + +import { Node } from "prosemirror-model"; +import { afterEach, beforeAll, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { YAttributionMarksExtension } from "../../../y/extensions/YAttributionMarks.js"; + +// Track editors created in each test so we can unmount them in afterEach — +// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that +// fires after vitest tears down jsdom, throwing +// `ReferenceError: document is not defined` and failing the run. +const activeEditors: BlockNoteEditor[] = []; + +afterEach(() => { + while (activeEditors.length) { + activeEditors.pop()!.unmount(); + } +}); + +/** + * The UniqueID extension's `appendTransaction` hook assigns a fresh id to any + * newly-inserted node whose id duplicates an existing one. The one exception is + * suggested-deletion nodes (carrying a `y-attributed-delete` mark): in + * suggestion mode, Yjs keeps the deleted node in the document with the SAME id + * as the surviving node, and rewriting that id would corrupt the suggestion. + * These tests exercise both branches. + */ + +function createEditor() { + // The suggested-deletion cases mark nodes with `y-attributed-delete`, which + // only exists in the schema when the suggestion-marks bundle is loaded. + const editor = BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); + editor.mount(document.createElement("div")); + activeEditors.push(editor); + editor.replaceBlocks(editor.document, [ + { id: "block-a", type: "paragraph", content: "A" }, + { id: "block-b", type: "paragraph", content: "B" }, + ]); + return editor; +} + +/** + * Builds a `blockContainer` node holding a single paragraph with the given + * block `id`, optionally carrying a `y-attributed-delete` mark to simulate a + * suggested deletion. + */ +function makeBlockContainer( + editor: BlockNoteEditor, + id: string, + text: string, + suggestedDelete: boolean, +) { + const schema = editor.pmSchema; + const paragraph = schema.nodes["paragraph"].createChecked( + {}, + schema.text(text), + ); + const marks = suggestedDelete + ? [schema.marks["y-attributed-delete"].create({ id: 1 })] + : undefined; + + return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); +} + +/** Returns the ids of all blockContainer nodes in document order. */ +function getBlockIds(doc: Node) { + const ids: (string | null)[] = []; + doc.descendants((node) => { + if (node.type.name === "blockContainer") { + ids.push(node.attrs.id); + } + return true; + }); + return ids; +} + +describe("UniqueID: duplicate id handling", () => { + let editor: BlockNoteEditor; + + beforeAll(() => { + // Reset the mock id counter so generated ids are deterministic. + (window as any).__TEST_OPTIONS = {}; + }); + + it("assigns a fresh id to a newly-inserted plain block that duplicates another new block", () => { + editor = createEditor(); + const view = editor._tiptapEditor.view; + + // Insert TWO new blocks sharing the same id "dup" in a single transaction. + // Both land in the same changed range, so UniqueID detects the duplicate + // and rewrites one of them with a fresh generated id. + const dup1 = makeBlockContainer(editor, "dup", "Dup 1", false); + const dup2 = makeBlockContainer(editor, "dup", "Dup 2", false); + + // Position at the boundary between the first block and the second block + // inside the blockGroup. + const firstBlock = view.state.doc.firstChild!.firstChild!; + const insertPos = firstBlock.nodeSize + 1; + + view.dispatch(view.state.tr.insert(insertPos, [dup1, dup2])); + + const ids = getBlockIds(view.state.doc); + + // Four blocks now exist, and UniqueID has resolved the duplicate so that + // all ids are distinct and non-null. + expect(ids).toHaveLength(4); + expect(ids.every((id) => id !== null)).toBe(true); + expect(new Set(ids).size).toBe(4); + }); + + it("preserves the duplicate id of a suggested-deletion block while still rewriting the plain duplicate", () => { + editor = createEditor(); + const view = editor._tiptapEditor.view; + + // Insert two new blocks sharing the id "dup" in a single transaction: a + // plain (live) one and a suggested-deletion one (y-attributed-delete mark). + // The plain block's id is rewritten, but the suggested-deletion block MUST + // keep its "dup" id, because in suggestion mode it intentionally shares the + // id with the surviving node. + const liveDup = makeBlockContainer(editor, "dup", "Live dup", false); + const deletedDup = makeBlockContainer(editor, "dup", "Deleted dup", true); + + const firstBlock = view.state.doc.firstChild!.firstChild!; + const insertPos = firstBlock.nodeSize + 1; + + // Insert the live block first, then the suggested-deletion block after it. + view.dispatch(view.state.tr.insert(insertPos, [liveDup, deletedDup])); + + const ids = getBlockIds(view.state.doc); + + expect(ids).toHaveLength(4); + // The suggested-deletion block keeps "dup". + const dupCount = ids.filter((id) => id === "dup").length; + expect(dupCount).toBe(1); + + // Confirm it is specifically the suggested-deletion node that kept "dup". + let suggestedDeletionId: string | null = null; + view.state.doc.descendants((node) => { + if ( + node.type.name === "blockContainer" && + node.marks.some((m) => m.type.name === "y-attributed-delete") + ) { + suggestedDeletionId = node.attrs.id; + } + return true; + }); + expect(suggestedDeletionId).toBe("dup"); + }); + + it("exposes distinct ids in editor.document even though two ProseMirror nodes share the same id", () => { + editor = createEditor(); + const view = editor._tiptapEditor.view; + + // Insert a suggested-deletion copy of the FIRST block, sharing its id + // "block-a". This mirrors suggestion mode: Yjs keeps the deleted node in + // the document with the same id as the surviving node, and UniqueID leaves + // that duplicate id untouched. + const deletedCopy = makeBlockContainer( + editor, + "block-a", + "A deleted copy", + true, + ); + + const firstBlock = view.state.doc.firstChild!.firstChild!; + const insertPos = firstBlock.nodeSize + 1; + + view.dispatch(view.state.tr.insert(insertPos, deletedCopy)); + + // At the ProseMirror level, two nodes now share the id "block-a": the live + // one and the suggested-deletion one. + const pmIds = getBlockIds(view.state.doc); + expect(pmIds.filter((id) => id === "block-a")).toHaveLength(2); + + // But editor.document disambiguates them via getNodeId: the suggested + // deletion node is reported as "block-a-1", so all block ids are distinct. + const docIds = editor.document.map((block) => block.id); + expect(docIds).toContain("block-a"); + expect(docIds).toContain("block-a-1"); + expect(new Set(docIds).size).toBe(docIds.length); + }); +}); diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 54cb8b7340..7ab30b78aa 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -4,9 +4,10 @@ import { findChildrenInRange, getChangedRanges, } from "@tiptap/core"; -import { Fragment, Slice } from "prosemirror-model"; -import { Plugin, PluginKey } from "prosemirror-state"; import { uuidv4 } from "lib0/random"; +import { Fragment, Node, Slice } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js"; /** * Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id) @@ -41,6 +42,20 @@ function findDuplicates(items: any) { return duplicates; } +/** + * Whether a node is marked as deleted by a suggestion (carries the + * `y-attributed-delete` node mark). + * + * Under the suggestion/matchNodes binding, changing a block's content type + * renders the block as a deleted copy (this mark) next to its inserted + * replacement - and both copies share the same `id`. The deleted copy must be + * ignored by the uniqueness logic, otherwise its `id` looks like a duplicate + * and we'd regenerate the `id` on the surviving block. + */ +function isMarkedDeleted(node: Node) { + return node.marks.some((mark) => mark.type.name === "y-attributed-delete"); +} + const UniqueID = Extension.create({ name: "uniqueID", // we’ll set a very high priority to make sure this runs first @@ -48,7 +63,6 @@ const UniqueID = Extension.create({ priority: 10000, addOptions() { return { - attributeName: "id", types: [] as string[], setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, @@ -74,19 +88,17 @@ const UniqueID = Extension.create({ { types: this.options.types, attributes: { - [this.options.attributeName]: { + id: { default: null, - parseHTML: (element) => - element.getAttribute(`data-${this.options.attributeName}`), + parseHTML: (element) => element.getAttribute(`data-id`), renderHTML: (attributes) => { const defaultIdAttributes = { - [`data-${this.options.attributeName}`]: - attributes[this.options.attributeName], + [`data-id`]: attributes.id, }; if (this.options.setIdAttribute) { return { ...defaultIdAttributes, - id: attributes[this.options.attributeName], + id: attributes.id, }; } else { return defaultIdAttributes; @@ -142,7 +154,7 @@ const UniqueID = Extension.create({ return; } const { tr } = newState; - const { types, attributeName, generateID } = this.options; + const { types, generateID } = this.options; const transform = combineTransactionSteps( oldState.doc, transactions as any, @@ -160,16 +172,20 @@ const UniqueID = Extension.create({ }, ); const newIds = newNodes - .map(({ node }) => node.attrs[attributeName]) + .map(({ node }) => node.attrs.id) .filter((id) => id !== null); const duplicatedNewIds = findDuplicates(newIds); newNodes.forEach(({ node, pos }) => { + // ignore ids on blocks marked as deleted (see above). + if (isMarkedDeleted(node)) { + return; + } // instead of checking `node.attrs[attributeName]` directly // we look at the current state of the node within `tr.doc`. // this helps to prevent adding new ids to the same node // if the node changed multiple times within one transaction - const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; + const id = tr.doc.nodeAt(pos)?.attrs.id; if (id === null) { // edge case, when using collaboration, yjs will set the id to null in `_forceRerender` @@ -193,7 +209,7 @@ const UniqueID = Extension.create({ // yes, apply the fix tr.setNodeMarkup(pos, undefined, { ...node.attrs, - [attributeName]: "initialBlockId", + id: "initialBlockId", }); return; } @@ -201,17 +217,18 @@ const UniqueID = Extension.create({ tr.setNodeMarkup(pos, undefined, { ...node.attrs, - [attributeName]: generateID(), + id: generateID(), }); return; } // check if the node doesn’t exist in the old state const { deleted } = mapping.invert().mapResult(pos); const newNode = deleted && duplicatedNewIds.includes(id); - if (newNode) { + // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them + if (newNode && !isSuggestedDeletionNode(node)) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, - [attributeName]: generateID(), + id: generateID(), }); } }); @@ -275,7 +292,7 @@ const UniqueID = Extension.create({ if (!transformPasted) { return slice; } - const { types, attributeName } = this.options; + const { types } = this.options; const removeId = (fragment: any) => { const list: any[] = []; fragment.forEach((node: any) => { @@ -293,7 +310,7 @@ const UniqueID = Extension.create({ const nodeWithoutId = node.type.create( { ...node.attrs, - [attributeName]: null, + id: null, }, removeId(node.content), node.marks, diff --git a/packages/core/src/extensions/tiptap-extensions/index.ts b/packages/core/src/extensions/tiptap-extensions/index.ts index 97f360182f..71d31935f4 100644 --- a/packages/core/src/extensions/tiptap-extensions/index.ts +++ b/packages/core/src/extensions/tiptap-extensions/index.ts @@ -1,7 +1,6 @@ export * from "./BackgroundColor/BackgroundColorExtension.js"; export * from "./HardBreak/HardBreak.js"; export * from "./KeyboardShortcuts/KeyboardShortcutsExtension.js"; -export * from "./Suggestions/SuggestionMarks.js"; export * from "./TextAlignment/TextAlignmentExtension.js"; export * from "./TextColor/TextColorExtension.js"; export * from "./UniqueID/UniqueID.js"; diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 37abc3e30b..67e090b3b8 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -386,6 +386,14 @@ export const ar: Dictionary = { more_replies: (count) => `${count} ردود أخرى`, }, }, + suggestion_changes: { + formatting_change: "تغيير التنسيق", + deleted: "محذوف", + inserted_by: (users: string) => `أُدرج بواسطة: ${users}`, + deleted_by: (users: string) => `حُذف بواسطة: ${users}`, + formatting_change_by: (formats: string, users: string) => + `تغيير التنسيق (${formats}) بواسطة: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 40944212b3..775ee44c4a 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -420,6 +420,14 @@ export const de: Dictionary = { more_replies: (count) => `${count} weitere Antworten`, }, }, + suggestion_changes: { + formatting_change: "Formatierungsänderung", + deleted: "Gelöscht", + inserted_by: (users: string) => `Eingefügt von: ${users}`, + deleted_by: (users: string) => `Gelöscht von: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Formatierungsänderung (${formats}) von: ${users}`, + }, generic: { ctrl_shortcut: "Strg", }, diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 5a9968eab2..727771fed8 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -401,6 +401,14 @@ export const en = { more_replies: (count: number) => `${count} more replies`, }, }, + suggestion_changes: { + formatting_change: "Formatting Change", + deleted: "Deleted", + inserted_by: (users: string) => `Inserted by: ${users}`, + deleted_by: (users: string) => `Deleted by: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Formatting change (${formats}) by: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 4757d9784f..609c945267 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -399,6 +399,14 @@ export const es: Dictionary = { more_replies: (count) => `${count} respuestas más`, }, }, + suggestion_changes: { + formatting_change: "Cambio de formato", + deleted: "Eliminado", + inserted_by: (users: string) => `Insertado por: ${users}`, + deleted_by: (users: string) => `Eliminado por: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Cambio de formato (${formats}) por: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index c9c67c1fee..e17eb4560a 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -369,6 +369,14 @@ export const fa = { more_replies: (count: number) => `${count} پاسخ دیگر`, }, }, + suggestion_changes: { + formatting_change: "تغییر قالب‌بندی", + deleted: "حذف\u200cشده", + inserted_by: (users: string) => `درج‌شده توسط: ${users}`, + deleted_by: (users: string) => `حذف‌شده توسط: ${users}`, + formatting_change_by: (formats: string, users: string) => + `تغییر قالب‌بندی (${formats}) توسط: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index b05d346409..e2d74679ca 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -447,6 +447,14 @@ export const fr: Dictionary = { more_replies: (count) => `${count} réponses de plus`, }, }, + suggestion_changes: { + formatting_change: "Modification de mise en forme", + deleted: "Supprimé", + inserted_by: (users: string) => `Inséré par : ${users}`, + deleted_by: (users: string) => `Supprimé par : ${users}`, + formatting_change_by: (formats: string, users: string) => + `Modification de mise en forme (${formats}) par : ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 797831460c..dfd8c6fe04 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -401,6 +401,14 @@ export const he: Dictionary = { more_replies: (count: number) => `${count} תגובות נוספות`, }, }, + suggestion_changes: { + formatting_change: "שינוי עיצוב", + deleted: "נמחק", + inserted_by: (users: string) => `נוסף על ידי: ${users}`, + deleted_by: (users: string) => `נמחק על ידי: ${users}`, + formatting_change_by: (formats: string, users: string) => + `שינוי עיצוב (${formats}) על ידי: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index c2081599cc..0ccca33e2c 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -414,6 +414,14 @@ export const hr: Dictionary = { more_replies: (count) => `${count} dodatnih odgovora`, }, }, + suggestion_changes: { + formatting_change: "Promjena oblikovanja", + deleted: "Izbrisano", + inserted_by: (users: string) => `Umetnuo/la: ${users}`, + deleted_by: (users: string) => `Izbrisao/la: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Promjena oblikovanja (${formats}) od: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index fcde471e56..881d2a7cf0 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -414,6 +414,14 @@ export const is: Dictionary = { more_replies: (count) => `${count} fleiri svör`, }, }, + suggestion_changes: { + formatting_change: "Sniðbreyting", + deleted: "Eytt", + inserted_by: (users: string) => `Sett inn af: ${users}`, + deleted_by: (users: string) => `Eytt af: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Sniðbreyting (${formats}) af: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 4053581107..541df5a09a 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -423,6 +423,14 @@ export const it: Dictionary = { more_replies: (count) => `${count} altre risposte`, }, }, + suggestion_changes: { + formatting_change: "Modifica formattazione", + deleted: "Eliminato", + inserted_by: (users: string) => `Inserito da: ${users}`, + deleted_by: (users: string) => `Eliminato da: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Modifica formattazione (${formats}) da: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index ce5ba87a77..dd9b31d484 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -441,6 +441,14 @@ export const ja: Dictionary = { more_replies: (count) => `${count} 件の追加返信`, }, }, + suggestion_changes: { + formatting_change: "書式の変更", + deleted: "削除済み", + inserted_by: (users: string) => `挿入者: ${users}`, + deleted_by: (users: string) => `削除者: ${users}`, + formatting_change_by: (formats: string, users: string) => + `書式の変更 (${formats}) 変更者: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 53a5def39e..dbbdc077aa 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -414,6 +414,14 @@ export const ko: Dictionary = { more_replies: (count) => `${count}개의 추가 답글`, }, }, + suggestion_changes: { + formatting_change: "서식 변경", + deleted: "삭제됨", + inserted_by: (users: string) => `삽입한 사람: ${users}`, + deleted_by: (users: string) => `삭제한 사람: ${users}`, + formatting_change_by: (formats: string, users: string) => + `서식 변경 (${formats}) 변경한 사람: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index a1bff3fc6b..09fc05c670 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -401,6 +401,14 @@ export const nl: Dictionary = { more_replies: (count) => `${count} extra reacties`, }, }, + suggestion_changes: { + formatting_change: "Opmaakwijziging", + deleted: "Verwijderd", + inserted_by: (users: string) => `Ingevoegd door: ${users}`, + deleted_by: (users: string) => `Verwijderd door: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Opmaakwijziging (${formats}) door: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 5d518d116b..ef644582bd 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -418,6 +418,14 @@ export const no: Dictionary = { more_replies: (count) => `${count} flere svar`, }, }, + suggestion_changes: { + formatting_change: "Formateringsendring", + deleted: "Slettet", + inserted_by: (users: string) => `Satt inn av: ${users}`, + deleted_by: (users: string) => `Slettet av: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Formateringsendring (${formats}) av: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index 614f64e9f2..058ef2b0b9 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -392,6 +392,14 @@ export const pl: Dictionary = { more_replies: (count) => `${count} więcej odpowiedzi`, }, }, + suggestion_changes: { + formatting_change: "Zmiana formatowania", + deleted: "Usunięto", + inserted_by: (users: string) => `Wstawione przez: ${users}`, + deleted_by: (users: string) => `Usunięte przez: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Zmiana formatowania (${formats}) przez: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index c12c94012e..4ff36b79af 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -393,6 +393,14 @@ export const pt: Dictionary = { more_replies: (count) => `${count} respostas a mais`, }, }, + suggestion_changes: { + formatting_change: "Alteração de formatação", + deleted: "Excluído", + inserted_by: (users: string) => `Inserido por: ${users}`, + deleted_by: (users: string) => `Excluído por: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Alteração de formatação (${formats}) por: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index 2982c8f5f6..b2c271e4bd 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -444,6 +444,14 @@ export const ru: Dictionary = { more_replies: (count) => `${count} дополнительных ответов`, }, }, + suggestion_changes: { + formatting_change: "Изменение форматирования", + deleted: "Удалено", + inserted_by: (users: string) => `Вставлено: ${users}`, + deleted_by: (users: string) => `Удалено: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Изменение форматирования (${formats}): ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index c24974f392..2c32015009 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -399,6 +399,14 @@ export const sk = { more_replies: (count: number) => `${count} ďalších odpovedí`, }, }, + suggestion_changes: { + formatting_change: "Zmena formátovania", + deleted: "Odstránené", + inserted_by: (users: string) => `Vložil: ${users}`, + deleted_by: (users: string) => `Odstránil: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Zmena formátovania (${formats}) od: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index a5d7d8f9af..5a2b45d940 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -425,6 +425,14 @@ export const uk: Dictionary = { more_replies: (count) => `${count} додаткових відповідей`, }, }, + suggestion_changes: { + formatting_change: "Зміна форматування", + deleted: "Видалено", + inserted_by: (users: string) => `Вставлено користувачем: ${users}`, + deleted_by: (users: string) => `Видалено користувачем: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Зміна форматування (${formats}) користувачем: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index ffc8d04ac6..ab16440d43 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -435,6 +435,14 @@ export const uz: Dictionary = { }, }, + suggestion_changes: { + formatting_change: "Formatlash o'zgarishi", + deleted: "O'chirildi", + inserted_by: (users: string) => `Qo'shgan: ${users}`, + deleted_by: (users: string) => `O'chirgan: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index cbe0e5e628..530df10fd1 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -400,6 +400,14 @@ export const vi: Dictionary = { more_replies: (count) => `${count} câu trả lời nữa`, }, }, + suggestion_changes: { + formatting_change: "Thay đổi định dạng", + deleted: "Đã xóa", + inserted_by: (users: string) => `Được chèn bởi: ${users}`, + deleted_by: (users: string) => `Được xóa bởi: ${users}`, + formatting_change_by: (formats: string, users: string) => + `Thay đổi định dạng (${formats}) bởi: ${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index b64912255f..95c13c86d4 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -442,6 +442,14 @@ export const zhTW: Dictionary = { more_replies: (count) => `還有 ${count} 則回覆`, }, }, + suggestion_changes: { + formatting_change: "格式變更", + deleted: "已刪除", + inserted_by: (users: string) => `插入者:${users}`, + deleted_by: (users: string) => `刪除者:${users}`, + formatting_change_by: (formats: string, users: string) => + `格式變更(${formats}),變更者:${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index ba5a2fe73b..551513c296 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -442,6 +442,14 @@ export const zh: Dictionary = { more_replies: (count) => `还有 ${count} 条回复`, }, }, + suggestion_changes: { + formatting_change: "格式更改", + deleted: "已删除", + inserted_by: (users: string) => `插入者:${users}`, + deleted_by: (users: string) => `删除者:${users}`, + formatting_change_by: (formats: string, users: string) => + `格式更改(${formats}),更改者:${users}`, + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d59e79ab8..981ba21e48 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; export * from "./api/getBlocksChangedByTransaction.js"; +export * from "./pm-nodes/suggestionMarks.js"; export * from "./api/nodeUtil.js"; export * from "./api/pmUtil.js"; export * from "./blocks/index.js"; @@ -18,6 +19,7 @@ export * from "./extensions/index.js"; export * from "./extensions-shared/UiElementPosition.js"; export * from "./i18n/dictionary.js"; export * from "./schema/index.js"; +export * from "./user/index.js"; export * from "./util/browser.js"; export * from "./util/combineByGroup.js"; export * from "./util/expandToWords.js"; diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 065c1e8c2f..86bd2ccb15 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -3,6 +3,7 @@ import { Node } from "@tiptap/core"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; +import { suggestionMarks } from "./suggestionMarks.js"; // Object containing all possible block attributes. const BlockAttributes: Record = { @@ -27,7 +28,9 @@ export const BlockContainer = Node.create<{ // Ensures content-specific keyboard handlers trigger first. priority: 50, defining: true, - marks: "insertion modification deletion", + marks() { + return suggestionMarks(this.editor); + }, parseHTML() { return [ { diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts index d98163310d..9fe644a5db 100644 --- a/packages/core/src/pm-nodes/BlockGroup.ts +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -1,6 +1,7 @@ import { Node } from "@tiptap/core"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; +import { suggestionMarks } from "./suggestionMarks.js"; export const BlockGroup = Node.create<{ domAttributes?: BlockNoteDOMAttributes; @@ -8,7 +9,9 @@ export const BlockGroup = Node.create<{ name: "blockGroup", group: "childContainer", content: "blockGroupChild+", - marks: "deletion insertion modification", + marks() { + return suggestionMarks(this.editor); + }, parseHTML() { return [ { diff --git a/packages/core/src/pm-nodes/Doc.ts b/packages/core/src/pm-nodes/Doc.ts index 40af17b7fa..b651820703 100644 --- a/packages/core/src/pm-nodes/Doc.ts +++ b/packages/core/src/pm-nodes/Doc.ts @@ -1,8 +1,11 @@ import { Node } from "@tiptap/core"; +import { suggestionMarks } from "./suggestionMarks.js"; export const Doc = Node.create({ name: "doc", topNode: true, content: "blockGroup", - marks: "insertion modification deletion", + marks() { + return suggestionMarks(this.editor); + }, }); diff --git a/packages/core/src/pm-nodes/suggestionMarks.ts b/packages/core/src/pm-nodes/suggestionMarks.ts new file mode 100644 index 0000000000..a3d0391083 --- /dev/null +++ b/packages/core/src/pm-nodes/suggestionMarks.ts @@ -0,0 +1,43 @@ +import { Editor, getExtensionField } from "@tiptap/core"; + +/** + * The mark schema group shared by every "suggestion" mark — both the Yjs + * collaboration marks (`y-attributed-*`) and the AI marks (`insertion` / + * `deletion` / `modification`). A mark opts into being allowed on block nodes by + * declaring this group in its spec (`group: BLOCK_LEVEL_SUGGESTION_GROUP`). + */ +export const BLOCK_LEVEL_SUGGESTION_GROUP = "blockLevelSuggestion"; + +/** + * Block node specs (blockContainer, blockGroup, doc, table cells, columns...) + * allow the suggestion marks so that a whole block can be marked inserted / + * deleted / modified in suggestion mode. + * + * These marks only exist when the extension that provides them is loaded; a node + * spec that referenced them unconditionally would make ProseMirror throw + * "Unknown mark type" while building the schema of an editor that doesn't have + * them (e.g. a plain, non-collaborative, non-AI editor) — this applies to a mark + * group with no members too. + * + * Use this as a node's `marks` field: it returns the {@link + * BLOCK_LEVEL_SUGGESTION_GROUP} group name (which ProseMirror expands to every + * mark in the group) when at least one such mark is registered on the editor, or + * `""` otherwise. It's evaluated at schema-build time, where the tiptap editor's + * fully-flattened extension list is available on `this.editor`. + */ +export function suggestionMarks(editor: Editor | undefined): string { + if (!editor) { + return ""; + } + const hasAttributionMark = editor.options.extensions.some((extension) => { + if (extension.type !== "mark") { + return false; + } + const group = getExtensionField(extension, "group") as string | undefined; + return ( + typeof group === "string" && + group.split(" ").includes(BLOCK_LEVEL_SUGGESTION_GROUP) + ); + }); + return hasAttributionMark ? BLOCK_LEVEL_SUGGESTION_GROUP : ""; +} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..958661d734 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -195,12 +195,7 @@ export function addNodeAndExtensionsToSpec< // Gets the BlockNote editor instance const editor = this.options.editor; // Gets the block - const block = getBlockFromPos( - props.getPos, - editor, - this.editor, - blockConfig.type, - ); + const block = getBlockFromPos(props.getPos, props.view.state.doc); // Gets the custom HTML attributes for `blockContent` nodes const blockContentDOMAttributes = this.options.domAttributes?.blockContent || {}; diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..210910eb99 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -1,18 +1,12 @@ -import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; +import { Attribute, Attributes, Node } from "@tiptap/core"; +import type { Node as PMNode } from "prosemirror-model"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; -import { InlineContentSchema } from "../inlineContent/types.js"; import { PropSchema, Props } from "../propTypes.js"; -import { StyleSchema } from "../styles/types.js"; -import { - BlockConfig, - BlockSchemaWithBlock, - LooseBlockSpec, - SpecificBlock, -} from "./types.js"; +import { LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -82,43 +76,20 @@ export function propsToAttributes(propSchema: PropSchema): Attributes { // Used to figure out which block should be rendered. This block is then used to // create the node view. -export function getBlockFromPos< - BType extends string, - Config extends BlockConfig, - BSchema extends BlockSchemaWithBlock, - I extends InlineContentSchema, - S extends StyleSchema, ->( - getPos: () => number | undefined, - editor: BlockNoteEditor, - tipTapEditor: Editor, - type: BType, -) { +export function getBlockFromPos(getPos: () => number | undefined, doc: PMNode) { + // TODO is there a cleaner implementation of this? Probably... const pos = getPos(); // Gets position of the node if (pos === undefined) { throw new Error("Cannot find node position"); } - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - if (!blockIdentifier) { - throw new Error("Block doesn't have id"); - } - - // Gets the block - const block = editor.getBlock(blockIdentifier)! as SpecificBlock< - BSchema, - BType, - I, - S - >; - if (block.type !== type) { - throw new Error("Block type does not match"); + // Gets parent blockContainer node + const blockContainer = doc.resolve(pos).node(); + if (!blockContainer) { + throw new Error("Cannot find block container"); } - + const block = nodeToBlock(blockContainer, doc); return block; } diff --git a/packages/core/src/user/UserStore.ts b/packages/core/src/user/UserStore.ts new file mode 100644 index 0000000000..eeb7bac749 --- /dev/null +++ b/packages/core/src/user/UserStore.ts @@ -0,0 +1,199 @@ +import { Store } from "@tanstack/store"; +import { createStore } from "../editor/BlockNoteExtension.js"; + +/** + * A collaborator of the document. + */ +export type User = { + /** + * The {@link User}'s unique identifier + */ + id: string; + /** + * The {@link User}'s name/label + */ + username: string; + /** + * The {@link User}'s profile image + */ + avatarUrl: string; + /** + * The color used to represent the user (e.g. for collaboration cursors). + */ + color?: string; + /** + * A lighter variant of {@link color}. + */ + colorLight?: string; +}; + +/** + * A store that retrieves and caches information about users, generic over the + * resolved user type `U`. + * + * Created via {@link createUserStore}. Features that need to resolve user ids to + * user information (comments, suggestions, versions) build one internally and + * expose it on their extension instance so their UI can read from it. + */ +export type UserStore = { + /** + * A store mapping user ids to the resolved {@link User} information. + */ + store: Store>; + /** + * Load information about users based on an array of user ids. + * + * Users that are already cached or currently being loaded are skipped, so + * it is safe to call this often (e.g. on every render). + */ + loadUsers: (userIds: User["id"][]) => Promise; + /** + * Re-fetch information about users, ignoring the cache. Users that are + * currently being loaded are still skipped to avoid duplicate requests. + */ + refetchUsers: (userIds: User["id"][]) => Promise; + /** + * Retrieve information about a user based on their id, if cached. + * + * The user has to be loaded via `loadUsers` first. + */ + getUser: (userId: User["id"]) => U | undefined; + /** + * Manually set information about a user. This is useful if you have a + * resolver that returns partial information (e.g. just the username) and you + * want to fill in the rest later (e.g. avatarUrl). + */ + setUser: (user: U | U[]) => void; +}; + +export type UserStoreResolver = ( + /** + * The user ids to resolve. The resolver should return information for all of + * these users, or an empty array if none could be resolved. + */ + userIds: User["id"][], + /** + * The {@link UserStore} that is calling this resolver. This allows you to return a user synchronously, and update the store later if you need to fetch additional information asynchronously. + */ + store: UserStore, +) => Promise; + +/** + * A resolver callback or an already-built {@link UserStore} — the shape that + * user-facing options (comments, collaboration) accept so callers can either let + * the extension build a store or pass a shared one. + */ +export type UserStoreOrResolver = + | ((userIds: User["id"][], store: UserStore) => Promise) + | UserStore; + +/** + * Creates a {@link UserStore} that retrieves and caches information about users. + * + * It does this by calling `resolveUsers` for users that are not yet cached, and + * stores the results in a BlockNote store so they can be subscribed to (e.g. via + * `useStore` in React). + * + * `resolveUsers` is called with the ids of users that are not yet cached, and + * should return the information for those users. The type of the returned users + * is inferred and flows through to {@link UserStore.getUser} and the store, so + * you can return a type with additional properties and have them be reported + * back. + * + * See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info. + */ +export function createUserStore( + resolveUsers: (userIds: User["id"][], store: UserStore) => Promise, +): UserStore { + if (!resolveUsers) { + throw new Error("resolveUsers is required to create a user store"); + } + + const store = createStore(new Map()); + + // Tracks users that are currently being fetched, to avoid duplicate + // in-flight requests. This is intentionally kept out of the store as it is + // not state that consumers need to subscribe to. + const loadingUsers = new Set(); + + const userStore: UserStore = { + store, + async loadUsers(userIds) { + const missingUsers = userIds.filter( + (id) => !store.state.has(id) && !loadingUsers.has(id), + ); + await fetchUsers(missingUsers); + }, + async refetchUsers(userIds) { + const usersToFetch = userIds.filter((id) => !loadingUsers.has(id)); + await fetchUsers(usersToFetch); + }, + getUser(userId) { + return store.state.get(userId); + }, + setUser(users) { + const usersArray = Array.isArray(users) ? users : [users]; + store.setState((prevState) => { + const nextState = new Map(prevState); + for (const user of usersArray) { + nextState.set(user.id, user); + } + return nextState; + }); + }, + }; + + async function fetchUsers(userIds: User["id"][]) { + if (userIds.length === 0) { + return; + } + + for (const id of userIds) { + loadingUsers.add(id); + } + + try { + const users = await resolveUsers(userIds, userStore); + // Only update the store if any users were actually resolved. Emitting + // an update when nothing changed (e.g. when the resolver can't find a + // user) would needlessly notify subscribers and, combined with a + // subscriber that re-triggers loading, could cause an infinite loop. + // See https://github.com/TypeCellOS/BlockNote/issues/1548 + if (users.length > 0) { + store.setState((prevState) => { + const nextState = new Map(prevState); + for (const user of users) { + nextState.set(user.id, user); + } + return nextState; + }); + } + } finally { + for (const id of userIds) { + // Remove the users from the loading set. On a next call to `loadUsers` + // we will either return the cached user, or retry loading the user if + // the request failed. + loadingUsers.delete(id); + } + } + } + + return userStore; +} + +/** + * Normalize a {@link UserStoreOrResolver} to a {@link UserStore}: + * - an existing store is returned as-is (so a single de-duped cache can be + * shared across extensions), + * - a resolver function is wrapped with {@link createUserStore}, + * - `undefined` yields an empty store that resolves nothing (consumers then fall + * back to showing raw user ids). + */ +export function normalizeToUserStore( + resolveUsersOrStore?: UserStoreOrResolver, +): UserStore { + if (typeof resolveUsersOrStore === "function") { + return createUserStore(resolveUsersOrStore); + } + return resolveUsersOrStore ?? createUserStore(async () => []); +} diff --git a/packages/core/src/user/createUserStore.test.ts b/packages/core/src/user/createUserStore.test.ts new file mode 100644 index 0000000000..70602daaeb --- /dev/null +++ b/packages/core/src/user/createUserStore.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, expectTypeOf, it, vi } from "vite-plus/test"; + +import { createUserStore, User } from "./index.js"; + +function setup(resolveUsers: (userIds: string[]) => Promise) { + return createUserStore(resolveUsers); +} + +function makeUser(id: string): User { + return { + id, + username: `user-${id}`, + avatarUrl: `https://example.com/${id}.png`, + }; +} + +describe("createUserStore", () => { + it("loads users into the store and exposes them via getUser", async () => { + const resolveUsers = vi.fn(async (ids: string[]) => ids.map(makeUser)); + const userStore = setup(resolveUsers); + + await userStore.loadUsers(["1", "2"]); + + expect(resolveUsers).toHaveBeenCalledTimes(1); + // The store itself is passed as the second argument, so resolvers can + // return synchronously and fill in more info via `setUser` later. + expect(resolveUsers).toHaveBeenCalledWith(["1", "2"], userStore); + expect(userStore.getUser("1")).toEqual(makeUser("1")); + expect(userStore.getUser("2")).toEqual(makeUser("2")); + expect(userStore.store.state.size).toBe(2); + }); + + it("does not re-fetch users that are already cached", async () => { + const resolveUsers = vi.fn(async (ids: string[]) => ids.map(makeUser)); + const userStore = setup(resolveUsers); + + await userStore.loadUsers(["1", "2"]); + await userStore.loadUsers(["2", "3"]); + + // Only the missing "3" should be requested the second time. + expect(resolveUsers).toHaveBeenCalledTimes(2); + expect(resolveUsers).toHaveBeenNthCalledWith(2, ["3"], userStore); + + await userStore.loadUsers(["1", "2", "3"]); + // Everything cached now → no further requests. + expect(resolveUsers).toHaveBeenCalledTimes(2); + }); + + it("de-duplicates concurrent in-flight requests for the same user", async () => { + const resolveUsers = vi.fn( + (ids: string[]) => + new Promise((resolve) => + setTimeout(() => resolve(ids.map(makeUser)), 10), + ), + ); + const userStore = setup(resolveUsers); + + await Promise.all([userStore.loadUsers(["1"]), userStore.loadUsers(["1"])]); + + expect(resolveUsers).toHaveBeenCalledTimes(1); + expect(userStore.getUser("1")).toEqual(makeUser("1")); + }); + + it("refetchUsers ignores the cache and re-resolves", async () => { + let counter = 0; + const resolveUsers = vi.fn(async (ids: string[]) => + ids.map((id) => ({ + ...makeUser(id), + username: `user-${id}-${counter++}`, + })), + ); + const userStore = setup(resolveUsers); + + await userStore.loadUsers(["1"]); + expect(userStore.getUser("1")?.username).toBe("user-1-0"); + + await userStore.refetchUsers(["1"]); + expect(resolveUsers).toHaveBeenCalledTimes(2); + expect(userStore.getUser("1")?.username).toBe("user-1-1"); + }); + + // Regression test for https://github.com/TypeCellOS/BlockNote/issues/1548 + it("does not emit a store update when a user cannot be resolved", async () => { + // Resolver that never returns the requested user (can't find it). + const resolveUsers = vi.fn(async () => [] as User[]); + const userStore = setup(resolveUsers); + + const onUpdate = vi.fn(); + const unsubscribe = userStore.store.subscribe(onUpdate); + + await userStore.loadUsers(["missing"]); + + // Nothing was resolved, so no update should be emitted (which previously + // could feed an infinite load loop in subscribers). + expect(onUpdate).not.toHaveBeenCalled(); + expect(userStore.getUser("missing")).toBeUndefined(); + + unsubscribe(); + }); + + it("infers a custom user type from resolveUsers' return type", async () => { + type CustomUser = User & { role: "admin" | "member" }; + + const resolveUsers = async (ids: string[]): Promise => + ids.map((id) => ({ ...makeUser(id), role: "admin" as const })); + + const userStore = createUserStore(resolveUsers); + + await userStore.loadUsers(["1"]); + + const user = userStore.getUser("1"); + // Type-level: the custom property flows through. + expectTypeOf(userStore.getUser).returns.toEqualTypeOf< + CustomUser | undefined + >(); + expectTypeOf(userStore.store.state).toEqualTypeOf< + Map + >(); + + // Runtime: the resolved user carries the extra field. + expect(user?.role).toBe("admin"); + }); +}); diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts new file mode 100644 index 0000000000..5aa6afbe0c --- /dev/null +++ b/packages/core/src/user/index.ts @@ -0,0 +1,2 @@ +export * from "./UserStore.js"; +export * from "./userColors.js"; diff --git a/packages/core/src/user/userColors.ts b/packages/core/src/user/userColors.ts new file mode 100644 index 0000000000..b63e909238 --- /dev/null +++ b/packages/core/src/user/userColors.ts @@ -0,0 +1,72 @@ +import { digestString } from "lib0/hash/fnv1a"; +import type { UserStore } from "./UserStore.js"; + +/** + * Deterministic hash of a string to an unsigned 32-bit integer. + */ +const hashStr = (s: string): number => { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = Math.imul(31, hash) + s.charCodeAt(i); + } + return Math.abs(hash); +}; + +/** Fallback palette used when a user has no resolved color of their own. */ +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" }, +]; + +/** The deterministic {@link userColorPalette} entry for a single user id. */ +export const fallbackColorForUserId = ( + id: string, +): { light: string; dark: string } => + userColorPalette[hashStr(id) % userColorPalette.length]; + +/** + * The (first) user's resolved color from the {@link UserStore}, 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. + */ +export const colorsForUserIds = ( + userStore: UserStore, + userIds: readonly string[] | undefined | null, +): { light: string; dark: string } => { + if (!userIds || userIds.length === 0) { + 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); +}; + +/** + * Reduce a user id to a fixed-width `[0-9a-f]` token safe to embed in a CSS + * custom-property name. Uses the (non-cryptographic) FNV-1a 32-bit hash; a + * collision only means two authors share a highlight color. + */ +export const cssVarUserId = (id: string): string => + digestString(id).toString(16).padStart(8, "0"); + +/** + * The `--user-color--{light,dark}` custom-property names for a user. Set on + * the editor root by `AttributionExtension`, read by the mark wrapper via + * `var(..., )`. + */ +export const userColorVarNames = ( + id: string, +): { light: string; dark: string } => { + const key = cssVarUserId(id); + return { + light: `--user-color-${key}-light`, + dark: `--user-color-${key}-dark`, + }; +}; diff --git a/packages/core/src/y/README.md b/packages/core/src/y/README.md new file mode 100644 index 0000000000..0a69f74ba9 --- /dev/null +++ b/packages/core/src/y/README.md @@ -0,0 +1,5 @@ +# @blocknote/core/y + +This package contains integrations for Yjs (v14) with BlockNote (based on `@y/y` & `@y/prosemirror`). Given that we are going to support both Yjs v13 & v14, we need to have a way to support both versions independently. + +If you want to use Yjs v13, you can use the `@blocknote/core/yjs` package instead which will use the `yjs` & `y-prosemirror` packages. diff --git a/packages/core/src/y/comments/RESTYjsThreadStore.ts b/packages/core/src/y/comments/RESTYjsThreadStore.ts new file mode 100644 index 0000000000..7841f453f4 --- /dev/null +++ b/packages/core/src/y/comments/RESTYjsThreadStore.ts @@ -0,0 +1,138 @@ +import * as Y from "@y/y"; +import type { CommentBody } from "../../comments/types.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; +import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js"; + +/** + * This is a REST-based implementation of the YjsThreadStoreBase for @y/y (v14). + * It Reads data directly from the underlying document (same as YjsThreadStore), + * but for Writes, it sends data to a REST API that should: + * - check the user has the correct permissions to make the desired changes + * - apply the updates to the underlying Yjs document + * + * (see https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus) + * + * The reason we still use the Yjs document as underlying storage is that it makes it easy to + * sync updates in real-time to other collaborators. + * (but technically, you could also implement a different storage altogether + * and not store the thread related data in the Yjs document) + */ +export class RESTYjsThreadStore extends YjsThreadStoreBase { + constructor( + private readonly BASE_URL: string, + private readonly headers: Record, + threadsYType: Y.Type, + auth: ThreadStoreAuth, + ) { + super(threadsYType, auth); + } + + private doRequest = async (path: string, method: string, body?: any) => { + const response = await fetch(`${this.BASE_URL}${path}`, { + method, + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + ...this.headers, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to ${method} ${path}: ${response.statusText}`); + } + + return response.json(); + }; + + public addThreadToDocument = async (options: { + threadId: string; + selection: { + head: number; + anchor: number; + }; + }) => { + const { threadId, ...rest } = options; + return this.doRequest(`/${threadId}/addToDocument`, "POST", rest); + }; + + public createThread = async (options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }) => { + return this.doRequest("", "POST", options); + }; + + public addComment = (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + }) => { + const { threadId, ...rest } = options; + return this.doRequest(`/${threadId}/comments`, "POST", rest); + }; + + public updateComment = (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + commentId: string; + }) => { + const { threadId, commentId, ...rest } = options; + return this.doRequest(`/${threadId}/comments/${commentId}`, "PUT", rest); + }; + + public deleteComment = (options: { + threadId: string; + commentId: string; + softDelete?: boolean; + }) => { + const { threadId, commentId, ...rest } = options; + return this.doRequest( + `/${threadId}/comments/${commentId}?soft=${!!rest.softDelete}`, + "DELETE", + ); + }; + + public deleteThread = (options: { threadId: string }) => { + return this.doRequest(`/${options.threadId}`, "DELETE"); + }; + + public resolveThread = (options: { threadId: string }) => { + return this.doRequest(`/${options.threadId}/resolve`, "POST"); + }; + + public unresolveThread = (options: { threadId: string }) => { + return this.doRequest(`/${options.threadId}/unresolve`, "POST"); + }; + + public addReaction = (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + const { threadId, commentId, ...rest } = options; + return this.doRequest( + `/${threadId}/comments/${commentId}/reactions`, + "POST", + rest, + ); + }; + + public deleteReaction = (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + return this.doRequest( + `/${options.threadId}/comments/${options.commentId}/reactions/${options.emoji}`, + "DELETE", + ); + }; +} diff --git a/packages/core/src/y/comments/YjsThreadStore.test.ts b/packages/core/src/y/comments/YjsThreadStore.test.ts new file mode 100644 index 0000000000..84ce8c47f4 --- /dev/null +++ b/packages/core/src/y/comments/YjsThreadStore.test.ts @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Y from "@y/y"; +import type { CommentBody } from "../../comments/types.js"; +import { DefaultThreadStoreAuth } from "../../comments/threadstore/DefaultThreadStoreAuth.js"; +import { YjsThreadStore } from "./YjsThreadStore.js"; + +// Mock UUID to generate sequential IDs +let mockUuidCounter = 0; +vi.mock("lib0/random", async (importOriginal) => ({ + ...(await importOriginal()), + uuidv4: () => `mocked-uuid-${++mockUuidCounter}`, +})); + +describe("YjsThreadStore (@y/y v14)", () => { + let store: YjsThreadStore; + let doc: Y.Doc; + let threadsYType: Y.Type; + + beforeEach(() => { + // Reset mocks and create fresh instances + vi.clearAllMocks(); + mockUuidCounter = 0; + doc = new Y.Doc(); + threadsYType = doc.get("threads"); + + store = new YjsThreadStore( + "test-user", + threadsYType, + new DefaultThreadStoreAuth("test-user", "editor"), + ); + }); + + describe("createThread", () => { + it("creates a thread with initial comment", async () => { + const initialComment = { + body: "Test comment" as CommentBody, + metadata: { extra: "metadatacomment" }, + }; + + const thread = await store.createThread({ + initialComment, + metadata: { extra: "metadatathread" }, + }); + + expect(thread).toMatchObject({ + type: "thread", + id: "mocked-uuid-2", + resolved: false, + metadata: { extra: "metadatathread" }, + comments: [ + { + type: "comment", + id: "mocked-uuid-1", + userId: "test-user", + body: "Test comment", + metadata: { extra: "metadatacomment" }, + reactions: [], + }, + ], + }); + }); + }); + + describe("addComment", () => { + it("adds a comment to existing thread", async () => { + // First create a thread + const thread = await store.createThread({ + initialComment: { + body: "Initial comment" as CommentBody, + }, + }); + + // Add new comment + const comment = await store.addComment({ + threadId: thread.id, + comment: { + body: "New comment" as CommentBody, + metadata: { test: "metadata" }, + }, + }); + + expect(comment).toMatchObject({ + type: "comment", + id: "mocked-uuid-3", + userId: "test-user", + body: "New comment", + metadata: { test: "metadata" }, + reactions: [], + }); + + // Verify thread has both comments + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments).toHaveLength(2); + }); + + it("throws error for non-existent thread", async () => { + await expect( + store.addComment({ + threadId: "non-existent", + comment: { + body: "Test comment" as CommentBody, + }, + }), + ).rejects.toThrow("Thread not found"); + }); + }); + + describe("updateComment", () => { + it("updates existing comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Initial comment" as CommentBody, + }, + }); + + await store.updateComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + comment: { + body: "Updated comment" as CommentBody, + metadata: { updatedMetadata: true }, + }, + }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments[0]).toMatchObject({ + body: "Updated comment", + metadata: { updatedMetadata: true }, + }); + }); + }); + + describe("deleteComment", () => { + it("soft deletes a comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + softDelete: true, + }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments[0].deletedAt).toBeDefined(); + expect(updatedThread.comments[0].body).toBeUndefined(); + }); + + it("hard deletes a comment (deletes thread)", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + softDelete: false, + }); + + // Thread should be deleted since it was the only comment + expect(() => store.getThread(thread.id)).toThrow("Thread not found"); + }); + }); + + describe("resolveThread", () => { + it("resolves a thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.resolveThread({ threadId: thread.id }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.resolved).toBe(true); + expect(updatedThread.resolvedUpdatedAt).toBeDefined(); + }); + }); + + describe("unresolveThread", () => { + it("unresolves a thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.resolveThread({ threadId: thread.id }); + await store.unresolveThread({ threadId: thread.id }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.resolved).toBe(false); + expect(updatedThread.resolvedUpdatedAt).toBeDefined(); + }); + }); + + describe("getThreads", () => { + it("returns all threads", async () => { + await store.createThread({ + initialComment: { + body: "Thread 1" as CommentBody, + }, + }); + + await store.createThread({ + initialComment: { + body: "Thread 2" as CommentBody, + }, + }); + + const threads = store.getThreads(); + expect(threads.size).toBe(2); + }); + }); + + describe("deleteThread", () => { + it("deletes an entire thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteThread({ threadId: thread.id }); + + // Verify thread is deleted + expect(() => store.getThread(thread.id)).toThrow("Thread not found"); + }); + }); + + describe("reactions", () => { + it("adds a reaction to a comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.addReaction({ + threadId: thread.id, + commentId: thread.comments[0].id, + emoji: "👍", + }); + + expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1); + }); + + it("deletes a reaction from a comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.addReaction({ + threadId: thread.id, + commentId: thread.comments[0].id, + emoji: "👍", + }); + + expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1); + + await store.deleteReaction({ + threadId: thread.id, + commentId: thread.comments[0].id, + emoji: "👍", + }); + + expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(0); + }); + }); + + describe("subscribe", () => { + it("calls callback when threads change", async () => { + const callback = vi.fn(); + const unsubscribe = store.subscribe(callback); + + await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); +}); diff --git a/packages/core/src/y/comments/YjsThreadStore.ts b/packages/core/src/y/comments/YjsThreadStore.ts new file mode 100644 index 0000000000..0a9b09a676 --- /dev/null +++ b/packages/core/src/y/comments/YjsThreadStore.ts @@ -0,0 +1,358 @@ +import { uuidv4 } from "lib0/random"; +import * as Y from "@y/y"; +import type { + CommentBody, + CommentData, + ThreadData, +} from "../../comments/types.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; +import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js"; +import { + commentToYType, + threadToYType, + yTypeToComment, + yTypeToThread, +} from "./yjsHelpers.js"; + +/** + * This is a @y/y (v14)-based implementation of the ThreadStore interface. + * + * It reads and writes thread / comments information directly to the underlying Yjs Document. + * + * @important While this is the easiest to add to your app, there are two challenges: + * - The user needs to be able to write to the Yjs document to store the information. + * So a user without write access to the Yjs document cannot leave any comments. + * - Even with write access, the operations are not secure. Unless your Yjs server + * guards against malicious operations, it's technically possible for one user to make changes to another user's comments, etc. + * (even though these options are not visible in the UI, a malicious user can make unauthorized changes to the underlying Yjs document) + */ +export class YjsThreadStore extends YjsThreadStoreBase { + constructor( + private readonly userId: string, + threadsYType: Y.Type, + auth: ThreadStoreAuth, + ) { + super(threadsYType, auth); + } + + private transact = ( + fn: (options: T) => R, + ): ((options: T) => Promise) => { + return async (options: T) => { + return this.threadsYType.doc!.transact(() => { + return fn(options); + }); + }; + }; + + public createThread = this.transact( + (options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }) => { + if (!this.auth.canCreateThread()) { + throw new Error("Not authorized"); + } + + const date = new Date(); + + const comment: CommentData = { + type: "comment", + id: uuidv4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + reactions: [], + metadata: options.initialComment.metadata, + body: options.initialComment.body, + }; + + const thread: ThreadData = { + type: "thread", + id: uuidv4(), + createdAt: date, + updatedAt: date, + comments: [comment], + resolved: false, + metadata: options.metadata, + }; + + this.threadsYType.setAttr(thread.id, threadToYType(thread)); + + return thread; + }, + ); + + // YjsThreadStore does not support addThreadToDocument + public addThreadToDocument = undefined; + + public addComment = this.transact( + (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + if (!this.auth.canAddComment(yTypeToThread(yThread))) { + throw new Error("Not authorized"); + } + + const date = new Date(); + const comment: CommentData = { + type: "comment", + id: uuidv4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + deletedAt: undefined, + reactions: [], + metadata: options.comment.metadata, + body: options.comment.body, + }; + + (yThread.getAttr("comments") as Y.Type).push([commentToYType(comment)]); + + yThread.setAttr("updatedAt", new Date().getTime()); + return comment; + }, + ); + + public updateComment = this.transact( + (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + commentId: string; + }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + const commentsType = yThread.getAttr("comments") as Y.Type; + const yCommentIndex = yTypeFindIndex( + commentsType, + (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = commentsType.get(yCommentIndex) as Y.Type; + + if (!this.auth.canUpdateComment(yTypeToComment(yComment))) { + throw new Error("Not authorized"); + } + + yComment.setAttr("body", options.comment.body); + yComment.setAttr("updatedAt", new Date().getTime()); + yComment.setAttr("metadata", options.comment.metadata); + }, + ); + + public deleteComment = this.transact( + (options: { + threadId: string; + commentId: string; + softDelete?: boolean; + }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + const commentsType = yThread.getAttr("comments") as Y.Type; + const yCommentIndex = yTypeFindIndex( + commentsType, + (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = commentsType.get(yCommentIndex) as Y.Type; + + if (!this.auth.canDeleteComment(yTypeToComment(yComment))) { + throw new Error("Not authorized"); + } + + if (yComment.getAttr("deletedAt")) { + throw new Error("Comment already deleted"); + } + + if (options.softDelete) { + yComment.setAttr("deletedAt", new Date().getTime()); + yComment.setAttr("body", undefined); + } else { + commentsType.delete(yCommentIndex); + } + + if ( + commentsType + .toArray() + .every((comment) => (comment as Y.Type).getAttr("deletedAt")) + ) { + // all comments deleted + if (options.softDelete) { + yThread.setAttr("deletedAt", new Date().getTime()); + } else { + this.threadsYType.deleteAttr(options.threadId); + } + } + + yThread.setAttr("updatedAt", new Date().getTime()); + }, + ); + + public deleteThread = this.transact((options: { threadId: string }) => { + if ( + !this.auth.canDeleteThread( + yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Type), + ) + ) { + throw new Error("Not authorized"); + } + + this.threadsYType.deleteAttr(options.threadId); + }); + + public resolveThread = this.transact((options: { threadId: string }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + if (!this.auth.canResolveThread(yTypeToThread(yThread))) { + throw new Error("Not authorized"); + } + + yThread.setAttr("resolved", true); + yThread.setAttr("resolvedUpdatedAt", new Date().getTime()); + yThread.setAttr("resolvedBy", this.userId); + }); + + public unresolveThread = this.transact((options: { threadId: string }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + if (!this.auth.canUnresolveThread(yTypeToThread(yThread))) { + throw new Error("Not authorized"); + } + + yThread.setAttr("resolved", false); + yThread.setAttr("resolvedUpdatedAt", new Date().getTime()); + }); + + public addReaction = this.transact( + (options: { threadId: string; commentId: string; emoji: string }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + const commentsType = yThread.getAttr("comments") as Y.Type; + const yCommentIndex = yTypeFindIndex( + commentsType, + (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = commentsType.get(yCommentIndex) as Y.Type; + + if (!this.auth.canAddReaction(yTypeToComment(yComment), options.emoji)) { + throw new Error("Not authorized"); + } + + const date = new Date(); + + const key = `${this.userId}-${options.emoji}`; + + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + + if (reactionsByUser.hasAttr(key)) { + // already exists + return; + } else { + const reaction = new Y.Type(); + reaction.setAttr("emoji", options.emoji); + reaction.setAttr("createdAt", date.getTime()); + reaction.setAttr("userId", this.userId); + reactionsByUser.setAttr(key, reaction); + } + }, + ); + + public deleteReaction = this.transact( + (options: { threadId: string; commentId: string; emoji: string }) => { + const yThread = this.threadsYType.getAttr(options.threadId) as + | Y.Type + | undefined; + if (!yThread) { + throw new Error("Thread not found"); + } + + const commentsType = yThread.getAttr("comments") as Y.Type; + const yCommentIndex = yTypeFindIndex( + commentsType, + (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = commentsType.get(yCommentIndex) as Y.Type; + + if ( + !this.auth.canDeleteReaction(yTypeToComment(yComment), options.emoji) + ) { + throw new Error("Not authorized"); + } + + const key = `${this.userId}-${options.emoji}`; + + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + + reactionsByUser.deleteAttr(key); + }, + ); +} + +function yTypeFindIndex(yType: Y.Type, predicate: (item: any) => boolean) { + for (let i = 0; i < yType.length; i++) { + if (predicate(yType.get(i))) { + return i; + } + } + return -1; +} diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts new file mode 100644 index 0000000000..b62c2e1811 --- /dev/null +++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts @@ -0,0 +1,50 @@ +import * as Y from "@y/y"; +import type { ThreadData } from "../../comments/types.js"; +import { ThreadStore } from "../../comments/threadstore/ThreadStore.js"; +import type { ThreadStoreAuth } from "../../comments/threadstore/ThreadStoreAuth.js"; +import { yTypeToThread } from "./yjsHelpers.js"; + +/** + * This is an abstract class that only implements the READ methods required by the ThreadStore interface. + * The data is read from a @y/y Type used as a map (via attributes). + */ +export abstract class YjsThreadStoreBase extends ThreadStore { + constructor( + protected readonly threadsYType: Y.Type, + auth: ThreadStoreAuth, + ) { + super(auth); + } + + // TODO: async / reactive interface? + public getThread(threadId: string) { + const yThread = this.threadsYType.getAttr(threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + const thread = yTypeToThread(yThread); + return thread; + } + + public getThreads(): Map { + const threadMap = new Map(); + this.threadsYType.forEachAttr((yThread: any, id: string | number) => { + if (yThread instanceof Y.Type) { + threadMap.set(String(id), yTypeToThread(yThread)); + } + }); + return threadMap; + } + + public subscribe(cb: (threads: Map) => void) { + const observer = () => { + cb(this.getThreads()); + }; + + this.threadsYType.observeDeep(observer); + + return () => { + this.threadsYType.unobserveDeep(observer); + }; + } +} diff --git a/packages/core/src/y/comments/index.ts b/packages/core/src/y/comments/index.ts new file mode 100644 index 0000000000..69e9f87de3 --- /dev/null +++ b/packages/core/src/y/comments/index.ts @@ -0,0 +1,3 @@ +export * from "./RESTYjsThreadStore.js"; +export * from "./YjsThreadStore.js"; +export * from "./YjsThreadStoreBase.js"; diff --git a/packages/core/src/y/comments/yjsHelpers.ts b/packages/core/src/y/comments/yjsHelpers.ts new file mode 100644 index 0000000000..1ed4ff492f --- /dev/null +++ b/packages/core/src/y/comments/yjsHelpers.ts @@ -0,0 +1,125 @@ +import * as Y from "@y/y"; +import type { + CommentData, + CommentReactionData, + ThreadData, +} from "../../comments/types.js"; + +export function commentToYType(comment: CommentData) { + const yType = new Y.Type(); + yType.setAttr("id", comment.id); + yType.setAttr("userId", comment.userId); + yType.setAttr("createdAt", comment.createdAt.getTime()); + yType.setAttr("updatedAt", comment.updatedAt.getTime()); + if (comment.deletedAt) { + yType.setAttr("deletedAt", comment.deletedAt.getTime()); + yType.setAttr("body", undefined); + } else { + yType.setAttr("body", comment.body); + } + if (comment.reactions.length > 0) { + throw new Error("Reactions should be empty in commentToYType"); + } + + /** + * Reactions are stored in a map keyed by {userId-emoji}, + * 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("metadata", comment.metadata); + + return yType; +} + +export function threadToYType(thread: ThreadData) { + const yType = new Y.Type(); + yType.setAttr("id", thread.id); + yType.setAttr("createdAt", thread.createdAt.getTime()); + yType.setAttr("updatedAt", thread.updatedAt.getTime()); + const commentsType = new Y.Type(); + + commentsType.push(thread.comments.map((comment) => commentToYType(comment))); + + yType.setAttr("comments", commentsType); + yType.setAttr("resolved", thread.resolved); + yType.setAttr("resolvedUpdatedAt", thread.resolvedUpdatedAt?.getTime()); + yType.setAttr("resolvedBy", thread.resolvedBy); + yType.setAttr("metadata", thread.metadata); + return yType; +} + +type SingleUserCommentReactionData = { + emoji: string; + createdAt: Date; + userId: string; +}; + +export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData { + return { + emoji: yType.getAttr("emoji"), + createdAt: new Date(yType.getAttr("createdAt")), + userId: yType.getAttr("userId"), + }; +} + +function yTypeToReactions(yType: Y.Type): CommentReactionData[] { + const flatReactions = [...yType.attrValues()].map((reaction: Y.Type) => + yTypeToReaction(reaction), + ); + // combine reactions by the same emoji + return flatReactions.reduce( + (acc: CommentReactionData[], reaction: SingleUserCommentReactionData) => { + const existingReaction = acc.find((r) => r.emoji === reaction.emoji); + if (existingReaction) { + existingReaction.userIds.push(reaction.userId); + existingReaction.createdAt = new Date( + Math.min( + existingReaction.createdAt.getTime(), + reaction.createdAt.getTime(), + ), + ); + } else { + acc.push({ + emoji: reaction.emoji, + createdAt: reaction.createdAt, + userIds: [reaction.userId], + }); + } + return acc; + }, + [] as CommentReactionData[], + ); +} + +export function yTypeToComment(yType: Y.Type): CommentData { + return { + type: "comment", + id: yType.getAttr("id"), + userId: yType.getAttr("userId"), + createdAt: new Date(yType.getAttr("createdAt")), + updatedAt: new Date(yType.getAttr("updatedAt")), + deletedAt: yType.getAttr("deletedAt") + ? new Date(yType.getAttr("deletedAt")) + : undefined, + reactions: yTypeToReactions(yType.getAttr("reactionsByUser")), + metadata: yType.getAttr("metadata"), + body: yType.getAttr("body"), + }; +} + +export function yTypeToThread(yType: Y.Type): 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), + ), + resolved: yType.getAttr("resolved"), + resolvedUpdatedAt: new Date(yType.getAttr("resolvedUpdatedAt")), + resolvedBy: yType.getAttr("resolvedBy"), + metadata: yType.getAttr("metadata"), + }; +} diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts new file mode 100644 index 0000000000..3c28d50771 --- /dev/null +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { User } from "../../user/index.js"; +import { AttributionExtension } from "./AttributionExtension.js"; + +// 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() { + const resolveUsers = vi.fn( + async (ids: string[]): Promise => + ids.map((id) => ({ + id, + username: `name-${id}`, + avatarUrl: "", + color: "#123456", + colorLight: "#abcdef", + })), + ); + + const editor = BlockNoteEditor.create({ + extensions: [AttributionExtension({ resolveUsers })], + }); + editor.mount(document.createElement("div")); + + return { editor, resolveUsers }; +} + +// 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[]) { + const markType = editor.pmSchema.marks["y-attributed-insert"]; + editor.transact((tr) => { + tr.doc.descendants((node, pos) => { + if (node.isText) { + tr.addMark(pos, pos + node.nodeSize, markType.create({ userIds })); + return false; + } + return true; + }); + }); +} + +describe("AttributionExtension user loading", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loads the authors when an attribution mark is added by a change", () => { + const { editor, resolveUsers } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + resolveUsers.mockClear(); + + addInsertMark(editor, ["alice"]); + + expect(resolveUsers).toHaveBeenCalledTimes(1); + // The extension's user store passes itself as the resolver's second arg. + expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything()); + }); + + it("does not load users for changes without attribution marks", () => { + const { editor, resolveUsers } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + resolveUsers.mockClear(); + + editor.replaceBlocks(editor.document, [{ content: "hello world" }]); + + expect(resolveUsers).not.toHaveBeenCalled(); + }); + + it("only requests each uncached author once across changes", () => { + const { editor, resolveUsers } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + resolveUsers.mockClear(); + + addInsertMark(editor, ["alice"]); + addInsertMark(editor, ["alice"]); + + // The user store dedupes already-cached ids, so `alice` is fetched once. + expect(resolveUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts new file mode 100644 index 0000000000..10e888830e --- /dev/null +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -0,0 +1,378 @@ +import { getChangedRanges } from "@tiptap/core"; +import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import { + createExtension, + createStore, + type ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; +import { + colorsForUserIds, + userColorVarNames, + normalizeToUserStore, + type UserStoreOrResolver, +} from "../../user/index.js"; +import { + resolveAttributionMarkClassName, + YAttributionMarksExtension, + type GetAttributionMarkClassName, +} from "./YAttributionMarks.js"; + +/** The attribution marks, mapped to their `modificationType`. */ +const ATTRIBUTION_MARK_TYPES = { + "y-attributed-insert": "insert", + "y-attributed-delete": "delete", + "y-attributed-format": "format", +} as const; + +const ATTRIBUTION_LOAD_PLUGIN_KEY = new PluginKey("attributionLoadUsers"); + +/** Wrapper of an attribution mark; carries its author(s) in `data-user-ids`. */ +const ATTRIBUTION_MARK_SELECTOR = "[data-user-ids]"; + +/** Parse the JSON-encoded `data-user-ids` attribute; `[]` if missing/malformed. */ +const parseUserIds = (userIdsJSON: string | undefined): string[] => { + if (!userIdsJSON) { + return []; + } + let userIds: unknown; + try { + userIds = JSON.parse(userIdsJSON); + } catch { + return []; + } + return Array.isArray(userIds) ? userIds.map(String) : []; +}; + +/** + * The changed format keys from a modification mark's `data-format` (e.g. + * `["bold", "italic"]`); `[]` if missing/malformed or an empty change. This is + * the raw change context — turning it into a localized label (e.g. + * `"Bold, Italic"`) is a view concern owned by the React layer's + * `formatChangeLabel`, so core stays i18n-agnostic here. + */ +const parseFormatKeys = (formatJSON: string | undefined): string[] => { + if (!formatJSON) { + return []; + } + let format: unknown; + try { + format = JSON.parse(formatJSON); + } catch { + return []; + } + if (typeof format !== "object" || format === null) { + return []; + } + 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 = { + /** The wrapper element the tooltip anchors to (floating-ui reference). */ + anchor: HTMLElement; + /** 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`. + */ + format?: string[]; + /** + * Class name from the `getAttributionMarkClassName` callback (override path). + * When present, the tooltip applies this and skips the inline `color`. + */ + className?: string; +}; + +/** + * Resolves the attribution tooltip state for suggestion marks on hover (exposed + * via a store for React), and loads each mark's author so its color/username + * resolves. Marks nest, so a single delegated `mouseover` listener picks the + * `closest` wrapper to the pointer — the innermost mark wins. + */ +export const AttributionExtension = createExtension( + ({ + options, + }: ExtensionOptions< + | { + /** Resolves authors to usernames. Optional; unresolved ids show raw. */ + resolveUsers?: UserStoreOrResolver; + /** See {@link GetAttributionMarkClassName}. */ + getAttributionMarkClassName?: GetAttributionMarkClassName; + } + | undefined + >) => { + const userStore = normalizeToUserStore(options?.resolveUsers); + const getAttributionMarkClassName = options?.getAttributionMarkClassName; + + const store = createStore(undefined); + + // Load the authors of the attribution marks in `tr`'s changed ranges, so + // their colors/usernames resolve (colors then flow to marks via `syncRootVars`). + // `getChangedRanges` covers mark-only steps too — which suggestion mode adds + // 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; + let to = -Infinity; + for (const { newRange } of ranges) { + from = Math.min(from, newRange.from); + to = Math.max(to, newRange.to); + } + + 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)); + } + } + return true; + }); + if (ids.size > 0) { + void userStore.loadUsers(Array.from(ids)); + } + }; + + return { + key: "attribution", + userStore, + store, + prosemirrorPlugins: [ + // Marks arrive in a transaction (suggestion mode, viewing suggestions, + // version preview), so resolve their authors as the doc changes. + new Plugin({ + key: ATTRIBUTION_LOAD_PLUGIN_KEY, + state: { + init: () => null, + apply: (tr) => { + if (tr.docChanged) { + loadChangedUsers(tr); + } + return null; + }, + }, + }), + ], + mount({ dom, root, signal }) { + // Write each resolved author's color to the editor root as per-user CSS + // variables (`--user-color--{light,dark}`) that the mark wrappers read + // via `var(..., )`, so the cascade recolors marks once a color + // resolves. Color-less users have theirs removed so the fallback applies. + 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); + } else { + dom.style.removeProperty(light); + dom.style.removeProperty(dark); + } + } + }; + + // The wrapper currently showing a tooltip, so we don't re-emit on every + // `mouseover` over the same mark. + let activeAnchor: HTMLElement | undefined; + + // 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`). + const usersLabelArray = (userIdsJSON: string | undefined): string[] => + parseUserIds(userIdsJSON).map( + (id) => userStore.getUser(id)?.username ?? id, + ); + + // 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. + 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(",")}`; + }; + + // 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"; + const contentType: AttributionTooltipState["contentType"] = + anchor.dataset["inline"] === "false" ? "block" : "inline-content"; + + return { + anchor, + // The tooltip is portaled outside the editor root, so it can't read + // the cascaded per-user vars — resolve a concrete color from the store. + color: colorsForUserIds( + userStore, + parseUserIds(anchor.dataset["userIds"]), + ).dark, + modificationType, + contentType, + users: usersLabelArray(anchor.dataset["userIds"]), + format: isModification + ? parseFormatKeys(anchor.dataset["format"]) + : undefined, + className: resolveAttributionMarkClassName( + getAttributionMarkClassName?.({ contentType, modificationType }), + "tooltip", + ), + }; + }; + + const hideTooltip = () => { + if (!activeAnchor) { + return; + } + activeAnchor = undefined; + 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; + } + return undefined; + }; + + const onPointerOver = (event: Event) => { + const target = event.target instanceof Element ? event.target : null; + const innermost = innermostAttributed(target); + if (!innermost) { + // Not over an attributed mark — drop the current tooltip. + hideTooltip(); + return; + } + + 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. + let anchor = innermost; + let el: Element | null = innermost.parentElement; + while (el) { + const ancestor = el.closest(ATTRIBUTION_MARK_SELECTOR); + if (!ancestor) { + break; + } + const ancestorIdentity = attributionIdentity(ancestor); + if (ancestorIdentity === identity) { + anchor = ancestor; + } else if (ancestorIdentity) { + break; + } + el = ancestor.parentElement; + } + + if (activeAnchor === anchor) { + return; + } + + activeAnchor = anchor; + store.setState(buildState(anchor)); + + // 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) { + return; + } + store.setState(buildState(anchor)); + }); + } + }; + + root.addEventListener("mouseover", onPointerOver, { signal }); + signal.addEventListener("abort", hideTooltip); + + // Seed from the cache, then keep the vars in sync as users resolve. + syncRootVars(); + const unsubscribe = userStore.store.subscribe(syncRootVars); + signal.addEventListener("abort", unsubscribe); + }, + + // The `y-attributed-*` marks aren't in the default schema — register them + // here so the block specs can allow them (collaboration-only). + blockNoteExtensions: [ + YAttributionMarksExtension({ + getAttributionMarkClassName: options?.getAttributionMarkClassName, + }), + ], + }; + }, +); diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts new file mode 100644 index 0000000000..968193b2bd --- /dev/null +++ b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { Block } from "../../blocks/defaultBlocks.js"; +import { AttributionExtension } from "./AttributionExtension.js"; +import { DiffVersioningExtension } from "./DiffVersioningExtension.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createDiffEditor() { + const editor = BlockNoteEditor.create({ + extensions: [DiffVersioningExtension()], + }); + editor.mount(document.createElement("div")); + return editor; +} + +/** + * Build a `Block[]` fixture from a plain paragraph string using a throwaway + * editor, mirroring the `mkSnap` pattern in `inMemoryVersioning.test.ts`. + */ +function blocksFromText(text: string): Block[] { + const e = BlockNoteEditor.create(); + e.mount(document.createElement("div")); + e.replaceBlocks(e.document, [{ type: "paragraph", content: text }]); + const blocks = e.document; + e.unmount(); + return blocks; +} + +/** + * Walk the rendered doc and collect, per text node, its text and the attribution + * marks on it: `{ text, marks: [[markName, userIds], ...] }`. Only y-attributed-* + * marks are recorded, so ordinary formatting doesn't pollute the assertion. + */ +function collectAttributedText(editor: BlockNoteEditor) { + const out: Array<{ text: string; marks: Array<[string, string[]]> }> = []; + editor.prosemirrorState.doc.descendants((node) => { + if (node.isText) { + const marks = node.marks + .filter((m) => m.type.name.startsWith("y-attributed-")) + .map( + (m) => + [m.type.name, (m.attrs.userIds as string[]) ?? []] as [ + string, + string[], + ], + ); + out.push({ text: node.text ?? "", marks }); + } + return true; + }); + return out; +} + +function attributionMarkNames( + editor: BlockNoteEditor, +): Set { + const names = new Set(); + editor.prosemirrorState.doc.descendants((node) => { + node.marks.forEach((m) => { + if (m.type.name.startsWith("y-attributed-")) { + names.add(m.type.name); + } + }); + return true; + }); + return names; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("DiffVersioningExtension", () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = createDiffEditor(); + }); + + afterEach(() => { + editor.unmount(); + }); + + it("registers the y-attributed-* marks into the schema", () => { + expect(editor.pmSchema.marks["y-attributed-insert"]).toBeDefined(); + expect(editor.pmSchema.marks["y-attributed-delete"]).toBeDefined(); + expect(editor.pmSchema.marks["y-attributed-format"]).toBeDefined(); + }); + + it("renders insert/delete marks in the right positions for a known diff", () => { + const baseline = blocksFromText("the quick brown fox"); + const target = blocksFromText("the slow brown fox jumps"); + + const diff = editor.getExtension(DiffVersioningExtension)!; + diff.renderDiff(target, baseline); + + const attributed = collectAttributedText(editor); + + // The rendered doc must contain both an insertion and a deletion. + const names = attributionMarkNames(editor); + expect(names.has("y-attributed-insert")).toBe(true); + expect(names.has("y-attributed-delete")).toBe(true); + + // "quick" is only in the baseline -> deleted. + const deleted = attributed + .filter((t) => t.marks.some(([n]) => n === "y-attributed-delete")) + .map((t) => t.text) + .join(""); + expect(deleted).toContain("quick"); + + // "slow" and "jumps" are only in the target -> inserted. + const inserted = attributed + .filter((t) => t.marks.some(([n]) => n === "y-attributed-insert")) + .map((t) => t.text) + .join(""); + expect(inserted).toContain("slow"); + expect(inserted).toContain("jumps"); + + // Unchanged text ("brown fox") carries no attribution marks. + const unchanged = attributed + .filter((t) => t.marks.length === 0) + .map((t) => t.text) + .join(""); + expect(unchanged).toContain("brown fox"); + }); + + it("attributes the diff to the version author id (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"); + + const attributed = collectAttributedText(editor); + const insertUserIds = attributed + .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"); + }); + + it("resolves the diff author to the version's name (tooltip label)", async () => { + const baseline = blocksFromText("hello world"); + const target = blocksFromText("hello brave new world"); + + const diff = editor.getExtension(DiffVersioningExtension)!; + diff.renderDiff(target, baseline, "Draft 3"); + + // 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"; + await attribution.userStore.loadUsers([authorId]); + expect(attribution.userStore.getUser(authorId)?.username).toBe("Draft 3"); + }); + + it("produces no attribution marks when the docs are identical", () => { + const same = blocksFromText("nothing changes here"); + + const diff = editor.getExtension(DiffVersioningExtension)!; + diff.renderDiff(same, same); + + expect(attributionMarkNames(editor).size).toBe(0); + expect(editor.prosemirrorState.doc.textContent).toContain( + "nothing changes here", + ); + }); + + it("clearDiff restores plain content with no attribution marks", () => { + const baseline = blocksFromText("first version"); + const target = blocksFromText("second version"); + const restore = blocksFromText("live document"); + + const diff = editor.getExtension(DiffVersioningExtension)!; + diff.renderDiff(target, baseline); + expect(attributionMarkNames(editor).size).toBeGreaterThan(0); + + diff.clearDiff(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 new file mode 100644 index 0000000000..4d69f558b5 --- /dev/null +++ b/packages/core/src/y/extensions/DiffVersioningExtension.ts @@ -0,0 +1,232 @@ +import { docToDelta } from "@y/prosemirror"; +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 { + _blocksToProsemirrorNode, + docDiffToDelta, + findTypeInOtherYdoc, + getProseMirrorTrFromYFragment, +} 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. + */ +const DIFF_AUTHOR_ID_PREFIX = "version:"; + +/** 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"; + +export type DiffVersioningExtensionOptions = { + /** + * The color used for the diff's attribution marks. Defaults to a blue. + */ + color?: string; + /** + * See {@link GetAttributionMarkClassName}. Forwarded to the underlying + * {@link AttributionExtension} to override mark styling by change type. + */ + getAttributionMarkClassName?: GetAttributionMarkClassName; +}; + +/** + * 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 + * 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(); + doc.on("beforeObserverCalls", (tr) => { + if (!tr.insertSet.isEmpty()) { + Y.insertIntoIdMap( + attrs.inserts, + Y.createIdMapFromIdSet(tr.insertSet, [ + Y.createContentAttribute("insert", userId), + ]), + ); + } + if (!tr.deleteSet.isEmpty()) { + Y.insertIntoIdMap( + attrs.deletes, + Y.createIdMapFromIdSet(tr.deleteSet, [ + Y.createContentAttribute("delete", userId), + ]), + ); + } + }); + return attrs; +} + +/** + * An opt-in extension that renders a read-only diff between two BlockNote + * documents (`Block[]`) directly in the editor, marking insertions/deletions + * with the `y-attributed-*` suggestion marks — the same visual result the Yjs + * collaboration adapter produces, but driven from two plain block arrays with + * no server and no live Yjs sync. + * + * 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. + * + * Registering this extension is what makes non-collaborative versioning + * (`inMemoryVersioning`) capable of showing diffs: the in-memory preview + * controller looks this up by key (`"diffVersioning"`) and delegates to + * {@link renderDiff}, falling back to a static document swap when it's absent. + * + * `renderDiff` is also a standalone, directly-callable API — you can register + * just this extension and call + * `editor.getExtension(DiffVersioningExtension).renderDiff(target, baseline)` + * to render a known diff (e.g. in tests). + * + * @example + * ```ts + * const editor = BlockNoteEditor.create({ + * extensions: [DiffVersioningExtension()], + * }); + * editor.getExtension(DiffVersioningExtension)!.renderDiff(target, baseline); + * ``` + */ +export const DiffVersioningExtension = createExtension( + ({ + options, + editor, + }: { + options: DiffVersioningExtensionOptions | undefined; + 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: