diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 958bb67ae86..106dee1b003 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -93,7 +93,9 @@ jobs: "packages/liveblocks-redux" "packages/liveblocks-zustand" "packages/liveblocks-yjs" "packages/liveblocks-react-ui" "packages/liveblocks-react-flow" + "packages/liveblocks-codemirror" "packages/liveblocks-lexical" "packages/liveblocks-react-lexical" "packages/liveblocks-node-lexical" + "packages/liveblocks-prosemirror" "packages/liveblocks-react-tiptap" "packages/liveblocks-emails" "packages/liveblocks-node-prosemirror" "packages/liveblocks-react-blocknote" @@ -118,6 +120,8 @@ jobs: "packages/liveblocks-yjs" "packages/liveblocks-react-lexical" "packages/liveblocks-node-lexical" "packages/liveblocks-react-ui" "packages/liveblocks-react-flow" + "packages/liveblocks-codemirror" "packages/liveblocks-lexical" + "packages/liveblocks-prosemirror" "packages/liveblocks-react-tiptap" "packages/liveblocks-emails" "packages/liveblocks-node-prosemirror" "packages/liveblocks-react-blocknote" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc84f181f26..fe1153f01ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -103,6 +103,10 @@ jobs: exit 1 fi + - name: Typecheck + if: needs.check_for_code_changes.outputs.changes == 'true' + run: pnpm run typecheck --filter ${{ matrix.pkg }} + - name: Run unit tests if: needs.check_for_code_changes.outputs.changes == 'true' run: pnpm run test:ci --filter ${{ matrix.pkg }} diff --git a/.gitignore b/.gitignore index dbcc708ee0b..e8a5b82d031 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ coverage/ dist/ node_modules/ +.pi-lens/ *.tsbuildinfo diff --git a/.syncpackrc.json b/.syncpackrc.json index f030c318283..80f3abe342e 100644 --- a/.syncpackrc.json +++ b/.syncpackrc.json @@ -13,6 +13,15 @@ "isIgnored": true }, + { + "label": "New Lexical package + e2e track 0.45; legacy react/node-lexical stay on 0.35", + "packages": [ + "@liveblocks/lexical", + "@liveblocks/next-lexical-liveblocks" + ], + "isIgnored": true + }, + { "label": "Peer dependencies are intentionally wider ranges", "dependencyTypes": ["peer"], diff --git a/CHANGELOG.md b/CHANGELOG.md index fb721252509..e5073338a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## vNEXT (not yet released) +## v3.24.0 + +This release introduces `LiveText` (beta), a collaborative rich-text data +structure for plain text with optional inline formatting. + +### `@liveblocks/client` + +- Storage updates delivered to deep subscribers now carry a `source` field, + saying whether the change was made by this client + (`{ origin: "local", via: "edit" | "undo" | "redo" }`) or by another one + (`{ origin: "remote" }`), see + [docs](https://liveblocks.io/docs/api-reference/liveblocks-client#update-source). +- Add `LiveText` for collaborative text editing with concurrent inserts, + deletes, and formatting changes. + +### `@liveblocks/codemirror`, `@liveblocks/lexical`, and `@liveblocks/prosemirror` + +- Introduce packages for integrating CodeMirror, Lexical, and ProseMirror + editors with Liveblocks Storage and `LiveText`. + +### `@liveblocks/react-tiptap` + +- Fix `useIsEditorReady()` so it correctly reports when the Tiptap editor is + ready. Thanks @danilowoz for the fix. + ## v3.23.1 ### `@liveblocks/react-ui` diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index 697ae1ef945..c221ec84659 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -1762,7 +1762,7 @@ const unsubscribeSteven = room.subscribe(steven, (updatedSteven) => { It’s also possible to subscribe to a Storage item and all of its children by passing an optional `isDeep` option in the third argument. In this case, the callback will be passed a list of updates instead of just the new Storage item. -Each such update is a `{ type, node, updates }` object. +Each such update is a `{ type, node, updates, source }` object. ```ts const { root } = await room.getStorage(); @@ -1772,9 +1772,10 @@ const unsubscribe = room.subscribe( (storageUpdates) => { for (const update of storageUpdates) { const { - type, // "LiveObject", "LiveList", or "LiveMap" + type, // "LiveObject", "LiveList", "LiveMap", or "LiveText" node, updates, + source, // Where the change came from, see below } = update; switch (type) { case "LiveObject": { @@ -1792,6 +1793,37 @@ const unsubscribe = room.subscribe( // update.node is the LiveList that has been updated, deleted, or modified break; } + case "LiveText": { + // updates[0]?.type; is "insert", "delete", or "format" + // update.node is the LiveText that has been updated + break; + } + } + } + }, + { isDeep: true } +); +``` + +#### Checking where a change came from [#update-source] + +Every update carries a `source` field, telling you where the change came from. +Use it to tell changes made by this client apart from changes made by others, +for example when syncing Storage into an editor that already applied the local +change itself. + +```ts +const unsubscribe = room.subscribe( + root, + (storageUpdates) => { + for (const update of storageUpdates) { + const { source } = update; + if (source.origin === "remote") { + // Changed by another client, and received over the network + } else if (source.via === "edit") { + // Changed by this client, as a regular edit + } else { + // Changed by this client, replayed from history ("undo" or "redo") } } }, @@ -1799,6 +1831,20 @@ const unsubscribe = room.subscribe( ); ``` + + + `"local"` if the change was made by this client, `"remote"` if it was made + by another client and reached this client over the network. + + + Only present when `origin` is `"local"`. How the change was made: a regular + edit, or a replay from the [undo/redo history](#Room.history). + + + +When a single notification merges local and remote changes to the same node, the +merged update is reported as `{ origin: "remote" }`. + #### Using async functions You use an `async` function inside the subscription callback, though bear in @@ -4523,7 +4569,7 @@ whiteboard, or cells in a spreadsheet. ### Data structures -Storage provides four data structures which you can use to build your +Storage provides five different data structures, which you can use to build your application. All structures are permanent and persist when all users have left the room, unlike [Presence](/docs/ready-made-features/presence) which is temporary. @@ -4543,6 +4589,11 @@ temporary. by their name. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. +- [`LiveText`][] - A collaborative rich-text document. Use this to store plain + text with optional inline formatting attributes (for example + `{ bold: true }`). Concurrent insertions, deletions, and formatting changes + are merged automatically using server-ordered operational transformation. + - [`LiveFile`][] - An immutable reference to a file uploaded to Liveblocks. Use this for large files such as images and videos. All file types are supported. Learn more about [file size limits](/docs/pricing/limits). @@ -5912,6 +5963,336 @@ converts all nested Live structures. +## LiveText + +The `LiveText` class represents a collaborative rich-text document that is +synchronized across clients. Use it to store plain text with optional inline +formatting attributes. To add typing, read more under +[typing Storage](#typing-storage). + +Unlike [`LiveList`][], [`LiveMap`][], and [`LiveObject`][], a `LiveText` node +cannot contain child Storage structures. It stores a flat sequence of text +segments, each with optional attributes. + +```ts +type Document = LiveText; +``` + +### Data format + +A `LiveText` document is serialized as an array of segments. Each segment is +either a plain string, or a tuple of `[text, attributes]`: + +```ts +import type { LiveTextData } from "@liveblocks/client"; + +// Plain text +const plain: LiveTextData = [["Hello world"]]; + +// Text with inline formatting +const formatted: LiveTextData = [["Hello ", { bold: true }], ["world"]]; +``` + +Attributes are JSON objects. Set an attribute to `null` to remove it from a +range (for example `{ bold: null }`). + +### new LiveText [#LiveText.constructor] + +Create an empty `LiveText`. + +```ts +const text = new LiveText(); +``` + +Create a `LiveText` with initial plain text. + +```ts +const text = new LiveText("Hello world"); +``` + +Create a `LiveText` with initial formatted data. + +```ts +const text = new LiveText([["Hello ", { bold: true }], ["world"]]); +``` + + + + The newly created `LiveText`. + + + + + + Initial plain text, or an array of text segments with optional attributes. + Defaults to an empty document. + + + +### insert [#LiveText.insert] + +Inserts text at the given index. Optionally applies inline attributes to the +inserted text. + +```ts +const text = new LiveText("Hello"); + +text.insert(5, " world"); +// "Hello world" + +text.insert(0, "Say: ", { italic: true }); +``` + +_Nothing_ + + + + The character index at which to insert. Values outside the document range + are clipped. + + + The text to insert. + + + Optional inline attributes to apply to the inserted text. + + + +### delete [#LiveText.delete] + +Deletes `length` characters starting at `index`. + +```ts +const text = new LiveText("Hello world"); + +text.delete(5, 6); +// "Hello" +``` + +_Nothing_ + + + + The character index at which to start deleting. + + + The number of characters to delete. + + + +### replace [#LiveText.replace] + +Replaces a range of text with new text. Equivalent to deleting the range and +inserting new text at the same index. + +```ts +const text = new LiveText("Hello world"); + +text.replace(0, 5, "Hi"); +// "Hi world" +``` + +_Nothing_ + + + + The character index at which to start replacing. + + + The number of characters to replace. + + + The replacement text. + + + Optional inline attributes to apply to the replacement text. + + + +### format [#LiveText.format] + +Applies or removes inline attributes on a range of text. Set an attribute to +`null` to remove it. + +```ts +const text = new LiveText("Hello world"); + +text.format(0, 5, { bold: true }); +// [["Hello", { bold: true }], [" world"]] + +text.format(0, 5, { bold: null }); +// [["Hello world"]] +``` + +_Nothing_ + + + + The character index at which to start formatting. + + + The number of characters to format. + + + Attributes to apply. Use `null` as a value to remove an attribute from the + range. + + + +### length [#LiveText.length] + +Returns the number of characters in the document. + +```ts +const text = new LiveText("Hello"); + +// 5 +text.length; +``` + + + + The document length in characters. + + + +_None_ + +### toString [#LiveText.toString] + +Returns the plain text content of the document, without attributes. Joining the +text from each segment in [`toJSON()`](#LiveText.toJSON) produces the same +result. + +```ts +const text = new LiveText([["Hello ", { bold: true }], ["world"]]); + +// "Hello world" +text.toString(); + +// Equivalent when working with LiveTextData: +text + .toJSON() + .map(([segmentText]) => segmentText) + .join(""); +``` + + + + The plain text content. + + + +_None_ + +### toJSON [#LiveText.toJSON] + +Returns a JSON-compatible snapshot of the document as a `LiveTextData` array. +The result is cached and only recomputed when the contents change. + +```ts +const text = new LiveText([["Hello ", { bold: true }], ["world"]]); + +// [["Hello ", { bold: true }], ["world"]] +text.toJSON(); +``` + + + + A plain JSON-compatible array of text segments. Always serializable — + `JSON.stringify()` works out of the box. + + + +_None_ + +### clone [#LiveText.clone] + +Returns a deep clone of this `LiveText` that does not share mutable state with +the original. + +```ts +const text = new LiveText("Hello"); +const clone = text.clone(); + +clone.insert(5, "!"); +// text is still "Hello" +``` + + + + A new `LiveText` instance with the same content. + + + +_None_ + +### Example: editing Storage [#LiveText.example] + +Here’s a complete example of creating a room with a `LiveText` document and +editing it collaboratively. + +```ts file="liveblocks.config.ts" +import { LiveText } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Storage: { + document: LiveText; + }; + } +} +``` + +```ts +import { LiveText, createClient } from "@liveblocks/client"; + +const client = createClient({ publicApiKey: "pk_..." }); +const { room } = client.enterRoom("my-room"); + +const { root } = await room.getStorage(); +const document = root.get("document"); + +// Insert text at the end +document.insert(document.length, "Hello world"); + +// Make the first word bold +document.format(0, 5, { bold: true }); + +// Listen for granular updates +const unsubscribe = room.subscribe( + root, + (updates) => { + for (const update of updates) { + if (update.type !== "LiveText") continue; + + for (const change of update.updates) { + if (change.type === "insert") { + console.log(`Inserted "${change.text}" at ${change.index}`); + } + } + } + }, + { isDeep: true } +); +``` + +Each update also carries a [`source`](#update-source), saying whether the change +was made by this client or by another one. + +`LiveText` edits support [undo and redo](#Room.history) when made through a +connected room. + +To build a collaborative [CodeMirror](https://codemirror.net) editor, use +[`@liveblocks/codemirror`](/docs/api-reference/liveblocks-codemirror). For +[ProseMirror](https://prosemirror.net/), use +[`@liveblocks/prosemirror`](/docs/api-reference/liveblocks-prosemirror). For +[Tiptap](https://tiptap.dev/), use +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). +To build a collaborative [Lexical](https://lexical.dev) editor with Storage, use +[`@liveblocks/lexical`](/docs/api-reference/liveblocks-lexical). + ## LiveFile The `LiveFile` class is an immutable Storage leaf that references file bytes @@ -6575,6 +6956,7 @@ const user = room.getSelf(); [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject +[`livetext`]: /docs/api-reference/liveblocks-client#LiveText [`lostconnectiontimeout`]: /docs/api-reference/liveblocks-client#createClientLostConnectionTimeout [`node-fetch`]: https://npmjs.com/package/node-fetch diff --git a/docs/pages/api-reference/liveblocks-codemirror.mdx b/docs/pages/api-reference/liveblocks-codemirror.mdx new file mode 100644 index 00000000000..caf593b9d75 --- /dev/null +++ b/docs/pages/api-reference/liveblocks-codemirror.mdx @@ -0,0 +1,318 @@ +--- +meta: + title: "@liveblocks/codemirror" + parentTitle: "API Reference" + description: "API Reference for the @liveblocks/codemirror package" +alwaysShowAllNavigationLevels: false +--- + +`@liveblocks/codemirror` provides CodeMirror 6 plugins that sync a document with +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText) in Storage and +display remote carets and selections. Read our +[React](/docs/get-started/react-codemirror) or +[Next.js](/docs/get-started/nextjs-codemirror) get started guides to learn more. + +## Setup + +Install Liveblocks, CodeMirror, and this package: + +```bash +npm install @liveblocks/client @liveblocks/react @liveblocks/codemirror codemirror +``` + +Each Liveblocks package should use the same version. + +Create a room with a +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText) document in Storage +and an initial presence shape for selection cursors: + +```tsx file="liveblocks.config.ts" +import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; +import { LiveText } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveblocksCodemirrorSelection | null; + }; + Storage: { + document: LiveText; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } +} + +export {}; +``` + +```tsx file="App.tsx" +"use client"; + +import { LiveText } from "@liveblocks/client"; +import { + ClientSideSuspense, + LiveblocksProvider, + RoomProvider, +} from "@liveblocks/react/suspense"; +import { Editor } from "./Editor"; + +export default function App() { + return ( + + + Loading…}> + + + + + ); +} +``` + +Attach the plugins after Storage has loaded. Create the editor with the +`LiveText` content and both plugins in the initial extensions: + +```tsx file="Editor.tsx" +"use client"; + +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { EditorView } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import type { LiveText, Room } from "@liveblocks/client"; +import { + createLiveblocksPresencePlugin, + createLiveblocksSyncPlugin, +} from "@liveblocks/codemirror"; +import { useRoom } from "@liveblocks/react/suspense"; + +export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root == null) { + return
Loading…
; + } + + return ; +} + +function EditorInner({ text }: { text: LiveText }) { + const room = useRoom(); + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current === null) return; + + const view = new EditorView({ + parent: containerRef.current, + state: EditorState.create({ + // +++ + doc: text.toString(), + extensions: [ + createLiveblocksSyncPlugin(room, text), + createLiveblocksPresencePlugin(room, text), + ], + // +++ + }), + }); + + return () => { + view.destroy(); + }; + }, [room, text]); + + return
; +} + +function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} +``` + +Add styles for remote carets and selections. This package does not ship a +stylesheet. + +```css file="globals.css" +.lb-remote-selection { + position: absolute; + background-color: color-mix(in srgb, var(--lb-remote-color) 25%, transparent); + border-radius: 1px; + pointer-events: none; + box-sizing: border-box; +} + +.lb-remote-caret { + position: absolute; + width: 0; + border-left: 2px solid var(--lb-remote-color); + pointer-events: none; + box-sizing: border-box; +} +``` + +## createLiveblocksSyncPlugin + +Keeps a CodeMirror document in sync with a +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText) node in Storage. +Local edits are written to Storage. Remote edits are applied to the editor. The +plugin also wires undo and redo to the room’s history: + +- `Mod-z` — undo +- `Mod-y` — redo +- `Shift-Mod-z` — redo on macOS + +```tsx +import { createLiveblocksSyncPlugin } from "@liveblocks/codemirror"; + +const sync = createLiveblocksSyncPlugin(room, text); +``` + + + + A CodeMirror extension to add to your editor state. + + + + + + The Liveblocks room, retrieved with + [`useRoom`](/docs/api-reference/liveblocks-react#useRoom) or + [`client.enterRoom`](/docs/api-reference/liveblocks-client#Client.enterRoom). + + + The [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) node to + sync with the editor document. + + + +## createLiveblocksPresencePlugin + +Broadcasts the local selection to other clients and renders remote carets and +selection highlights. Remote caret colors come from each user’s +[`user.info.color`](/docs/api-reference/liveblocks-client#Room.getSelf). Set +user info when authenticating or joining a room. + +The plugin renders elements with the `.lb-remote-caret` and +`.lb-remote-selection` class names. Style them in your app CSS using the +`--lb-remote-color` CSS variable. + +```tsx +import { createLiveblocksPresencePlugin } from "@liveblocks/codemirror"; + +const presence = createLiveblocksPresencePlugin(room, text); +``` + +Add the returned extensions to your editor: + +```tsx +extensions: [ + createLiveblocksSyncPlugin(room, text), + createLiveblocksPresencePlugin(room, text), +]; +``` + + + + CodeMirror extensions that track remote selections and render carets. + + + + + + The Liveblocks room. Presence must include a `selection` field. See + [Typing](#Typing). + + + The same [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) node + passed to [`createLiveblocksSyncPlugin`](#createLiveblocksSyncPlugin). + + + +## LiveblocksCodemirrorSelection [#LiveblocksCodemirrorSelection] + +The presence selection shape used by +[`createLiveblocksPresencePlugin`](#createLiveblocksPresencePlugin). Positions +are encoded against the +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText) version so remote +carets stay stable across concurrent edits. + +```ts +import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; + +type Presence = { + selection: LiveblocksCodemirrorSelection | null; +}; +``` + + + + Encoded selection anchor index. + + + Encoded selection head index. + + + The [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) version + used when encoding `anchor` and `head`. + + + +## Typing [#Typing] + +Type your room’s presence, Storage, and user metadata in +[`liveblocks.config.ts`](/docs/api-reference/liveblocks-react#Typing-your-data). +Use [`LiveblocksCodemirrorSelection`](#LiveblocksCodemirrorSelection) for the +presence `selection` field. + +```ts file="liveblocks.config.ts" +import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; +import { LiveText } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveblocksCodemirrorSelection | null; + }; + Storage: { + document: LiveText; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } +} + +export {}; +``` + +When joining a room, set `initialPresence` to `{ selection: null }` and +`initialStorage` to your `LiveText` document: + +```tsx + + {/* children */} + +``` diff --git a/docs/pages/api-reference/liveblocks-lexical.mdx b/docs/pages/api-reference/liveblocks-lexical.mdx new file mode 100644 index 00000000000..77edcd07406 --- /dev/null +++ b/docs/pages/api-reference/liveblocks-lexical.mdx @@ -0,0 +1,326 @@ +--- +meta: + title: "@liveblocks/lexical" + parentTitle: "API Reference" + description: "API Reference for the @liveblocks/lexical package" +alwaysShowAllNavigationLevels: false +--- + +`@liveblocks/lexical` provides React plugins that sync a [Lexical](https://lexical.dev/) +editor with a Storage document tree and display remote carets and selections. +Read our [React](/docs/get-started/react-lexical-storage) or +[Next.js](/docs/get-started/nextjs-lexical-storage) get started guides to learn +more. + + + +This package uses Liveblocks Storage. For Comments, mentions, and the full Text +Editor product, use +[`@liveblocks/react-lexical`](/docs/api-reference/liveblocks-react-lexical) +instead. + + + +## Setup + +Install Liveblocks, Lexical, and this package: + +```bash +npm install @liveblocks/client @liveblocks/react @liveblocks/lexical lexical @lexical/react @lexical/selection @lexical/utils +``` + +Each Liveblocks package should use the same version. + +Create a room with a root Storage document and an initial presence shape for +selection cursors. The document is a +[`LiveObject`](/docs/api-reference/liveblocks-client#LiveObject) tree whose text +leaves use [`LiveText`](/docs/api-reference/liveblocks-client#LiveText): + +```ts file="liveblocks.config.ts" +import type { LiveLexicalSelection, LiveRootNode } from "@liveblocks/lexical"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveLexicalSelection | null; + }; + Storage: { + document: LiveRootNode; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } +} + +export {}; +``` + +```tsx file="App.tsx" +"use client"; + +import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; +import { + ClientSideSuspense, + LiveblocksProvider, + RoomProvider, +} from "@liveblocks/react/suspense"; +import { Editor } from "./Editor"; + +export default function App() { + return ( + + + Loading…
}> + + + + + ); +} +``` + +Wait for Storage to load, then nest +[`LiveblocksCollaborationPlugin`](#LiveblocksCollaborationPlugin) inside +[`LexicalComposer`](https://lexical.dev/docs/react/plugins). Optionally add +[`RemoteCursorsPlugin`](#RemoteCursorsPlugin) as a child to show remote carets: + +```tsx file="Editor.tsx" +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { + LiveblocksCollaborationPlugin, + RemoteCursorsPlugin, +} from "@liveblocks/lexical"; +import type { Room } from "@liveblocks/client"; +import { useRoom } from "@liveblocks/react/suspense"; +import "@liveblocks/lexical/styles.css"; + +export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root === null) { + return
Loading…
; + } + + const document = root.get("document"); + + return ( + console.error(error), + }} + > +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + // +++ + + + + // +++ +
+
+ ); +} + +function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} +``` + +Import the package stylesheet so remote carets and selections are visible: + +```tsx +import "@liveblocks/lexical/styles.css"; +``` + +## LiveblocksCollaborationPlugin + +Syncs the Lexical editor with a Storage +[`LiveRootNode`](#LiveRootNode). Local edits are written to Storage. Remote edits +are applied to the editor. Undo and redo use the room’s history. + +Must be nested inside [`LexicalComposer`](https://lexical.dev/docs/react/plugins) +and a Liveblocks [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). +Pass the Storage root document as `root`. + +```tsx +import { LiveblocksCollaborationPlugin } from "@liveblocks/lexical"; + + + + +``` + + + + The Storage root document for the editor. Typically + `root.get("document")` after Storage has loaded. + + + Optional children. Place [`RemoteCursorsPlugin`](#RemoteCursorsPlugin) here + to render remote carets and selections. + + + +## RemoteCursorsPlugin + +Renders remote carets and selection highlights for other users in the room. +Must be a child of +[`LiveblocksCollaborationPlugin`](#LiveblocksCollaborationPlugin). + +Caret colors come from each user’s +[`user.info.color`](/docs/api-reference/liveblocks-client#Room.getSelf). Set +user info when authenticating or joining a room. + +```tsx +import { + LiveblocksCollaborationPlugin, + RemoteCursorsPlugin, +} from "@liveblocks/lexical"; + + + // +++ + + // +++ + +``` + +Import `@liveblocks/lexical/styles.css` for the default cursor styles. The +plugin uses `--lb-lexical-cursor-color` and the class names +`.lb-lexical-cursor-caret` and `.lb-lexical-cursor-selection`. + +## LiveRootNode [#LiveRootNode] + +The Storage type for the collaborative document root. It is a +[`LiveObject`](/docs/api-reference/liveblocks-client#LiveObject) with +`kind: "root"`, a `children` [`LiveList`](/docs/api-reference/liveblocks-client#LiveList), +and nested element, text, linebreak, and decorator nodes. Text leaves store +content in [`LiveText`](/docs/api-reference/liveblocks-client#LiveText). + +```ts +import type { LiveRootNode } from "@liveblocks/lexical"; +import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; + +const document: LiveRootNode = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(), + }), + ]), + }), + ]), +}); +``` + +## LiveLexicalSelection [#LiveLexicalSelection] + +The presence selection shape used by this package. Positions are Storage- +relative (stable LiveObject ids and offsets), not Lexical node keys, so remote +carets stay stable across concurrent edits. + +```ts +import type { LiveLexicalSelection } from "@liveblocks/lexical"; + +type Presence = { + selection: LiveLexicalSelection | null; +}; +``` + + + + Selection anchor point in Storage coordinates. + + + Selection focus point in Storage coordinates. + + + +## Typing [#Typing] + +Type your room’s presence, Storage, and user metadata in +[`liveblocks.config.ts`](/docs/api-reference/liveblocks-react#Typing-your-data). +Use [`LiveLexicalSelection`](#LiveLexicalSelection) for the presence +`selection` field and [`LiveRootNode`](#LiveRootNode) for Storage. + +```ts file="liveblocks.config.ts" +import type { LiveLexicalSelection, LiveRootNode } from "@liveblocks/lexical"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveLexicalSelection | null; + }; + Storage: { + document: LiveRootNode; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } +} + +export {}; +``` + +When joining a room, set `initialPresence` to `{ selection: null }` and +`initialStorage` to a root document tree as shown in [Setup](#Setup). diff --git a/docs/pages/api-reference/liveblocks-node-prosemirror.mdx b/docs/pages/api-reference/liveblocks-node-prosemirror.mdx index c7ec9250f12..2f39b5f8427 100644 --- a/docs/pages/api-reference/liveblocks-node-prosemirror.mdx +++ b/docs/pages/api-reference/liveblocks-node-prosemirror.mdx @@ -9,7 +9,12 @@ alwaysShowAllNavigationLevels: false `@liveblocks/node-prosemirror` provides a Node.js package to export and modify [ProseMirror](https://prosemirror.net/). Because Tiptap uses ProseMirror under the hood, this package can be used to modify -[Tiptap](/docs/api-reference/liveblocks-react-tiptap) documents as well. +[Tiptap](/docs/api-reference/liveblocks-react-tiptap) documents as well. For a +client-side ProseMirror editor backed by `LiveText` in Storage, use +[`@liveblocks/prosemirror`](/docs/api-reference/liveblocks-prosemirror). For a +client-side Tiptap editor using the same Storage-backed collaboration, use +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). ## withProsemirrorDocument diff --git a/docs/pages/api-reference/liveblocks-prosemirror.mdx b/docs/pages/api-reference/liveblocks-prosemirror.mdx new file mode 100644 index 00000000000..dbc2b43f69e --- /dev/null +++ b/docs/pages/api-reference/liveblocks-prosemirror.mdx @@ -0,0 +1,489 @@ +--- +meta: + title: "@liveblocks/prosemirror" + parentTitle: "API Reference" + description: "API Reference for the @liveblocks/prosemirror package" +alwaysShowAllNavigationLevels: false +--- + +`@liveblocks/prosemirror` provides [ProseMirror](https://prosemirror.net/) +plugins that sync editor documents with Liveblocks Storage and display remote +carets and selections. Text nodes are stored as +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText), preserving text +formatting and concurrent edits. + +If you are using Tiptap, use +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) +instead. It builds on this package and provides a Tiptap extension and React +components. + + + +This package is for client-side ProseMirror editors backed by Liveblocks +Storage. For server-side editing of existing Tiptap and BlockNote documents, use +[`@liveblocks/node-prosemirror`](/docs/api-reference/liveblocks-node-prosemirror). + + + +## Setup + +Install Liveblocks and the ProseMirror packages used by your editor: + +```bash +npm install @liveblocks/client @liveblocks/react @liveblocks/prosemirror prosemirror-model prosemirror-state prosemirror-view +``` + +Each Liveblocks package should use the same version. + +Add the collaboration plugin to sync the document and the caret plugin to show +other users’ selections. The collaboration plugin creates its Storage document +when it first loads, so `initialStorage` can remain empty. + +```tsx file="Editor.tsx" +"use client"; + +import { useEffect, useRef } from "react"; +import { + createLiveblocksCollaborationCaretPlugin, + createLiveblocksCollaborationPlugin, +} from "@liveblocks/prosemirror"; +// +++ +import "@liveblocks/prosemirror/styles.css"; +// +++ +import { useRoom } from "@liveblocks/react/suspense"; +import { EditorState } from "prosemirror-state"; +import type { Schema } from "prosemirror-model"; +import { EditorView } from "prosemirror-view"; + +const INITIAL_CONTENT = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], +}; + +export function Editor({ schema }: { schema: Schema }) { + const room = useRoom(); + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current === null) return; + + const info = room.getSelf()?.info; + const user = { + name: typeof info?.name === "string" ? info.name : undefined, + color: typeof info?.color === "string" ? info.color : undefined, + }; + const caretStorage = { users: [] }; + + const state = EditorState.create({ + schema, + // +++ + plugins: [ + createLiveblocksCollaborationPlugin({ + room, + field: "document", + initialContent: INITIAL_CONTENT, + fallbackDocument: () => INITIAL_CONTENT, + }), + createLiveblocksCollaborationCaretPlugin( + { room, field: "document", user }, + caretStorage + ), + ], + // +++ + }); + + const view = new EditorView(containerRef.current, { state }); + return () => view.destroy(); + }, [room, schema]); + + return
; +} +``` + +The `field` value identifies the editor document. Documents are stored under +`root._tiptap_docs`, keyed by `field`, so use a different value for each editor +in the same room. + +Import the package stylesheet to display remote carets and selections: + +```ts +import "@liveblocks/prosemirror/styles.css"; +``` + +## createLiveblocksCollaborationPlugin [#createLiveblocksCollaborationPlugin] + +Creates a ProseMirror plugin that keeps an editor document in sync with +Liveblocks Storage. Local transactions update the Storage document, while remote +Storage changes are applied to the editor. Text leaves are represented by +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText). + +```ts +import { createLiveblocksCollaborationPlugin } from "@liveblocks/prosemirror"; + +const collaborationPlugin = createLiveblocksCollaborationPlugin({ + room, + field: "document", + initialContent: { + type: "doc", + content: [{ type: "paragraph" }], + }, +}); +``` + +The plugin groups local edits into the room’s undo and redo history. Connect +your editor’s undo and redo controls to `room.history.undo()` and +`room.history.redo()`. + + + + A ProseMirror plugin to add when creating the editor state. + + + + + + The Liveblocks room, retrieved with + [`useRoom`](/docs/api-reference/liveblocks-react#useRoom) or + [`client.enterRoom`](/docs/api-reference/liveblocks-client#Client.enterRoom). + + + The name used to store this editor under `root._tiptap_docs`. Use a unique + field for each editor in a room. + + + The initial ProseMirror JSON document. It is used only when the Storage + document does not exist. If omitted, the editor’s current document is used. + + + Returns a schema-valid document if stored content cannot be parsed or is + empty. + + + +## createLiveblocksCollaborationCaretPlugin [#createLiveblocksCollaborationCaretPlugin] + +Creates a ProseMirror plugin that broadcasts the local selection through +Presence and renders other users’ carets and selection highlights. + +```ts +import { + createLiveblocksCollaborationCaretPlugin, + type CollaborationCaretStorage, +} from "@liveblocks/prosemirror"; + +const storage: CollaborationCaretStorage = { users: [] }; +const caretPlugin = createLiveblocksCollaborationCaretPlugin( + { + room, + field: "document", + user: { name: "Ada", color: "#D583F0" }, + }, + storage +); +``` + +The plugin renders elements with the `.collaboration-carets__caret`, +`.collaboration-carets__label`, and `.collaboration-carets__selection` class +names. The user’s `color` is applied with inline styles. + + + + A ProseMirror plugin to add when creating the editor state. + + + + + + The same Liveblocks room passed to + [`createLiveblocksCollaborationPlugin`](#createLiveblocksCollaborationPlugin). + + + The same document field passed to the collaboration plugin. Cursors from + other fields are ignored. + + + The name and color displayed with this user’s remote caret. + + + A mutable object with a `users` array. The plugin updates the array with the + other users currently in the room. + + + +### Caret utilities [#Caret-utilities] + +Use `presencePatch` when building a wrapper around the caret plugin or updating +its user data outside the plugin. It creates the Presence update expected by +other `@liveblocks/prosemirror` clients. + +```ts +import { getCursorUser, presencePatch } from "@liveblocks/prosemirror"; + +const user = getCursorUser(room.getSelf()?.info) ?? {}; + +room.updatePresence( + presencePatch({ + field: "document", + anchor: editorState.selection.anchor, + head: editorState.selection.head, + user, + }) +); +``` + + + + Creates a Presence patch containing the document field, selection positions, + and optional cursor user. + + + Reads string `name` and `color` properties from an unknown value. + + + +`LIVEBLOCKS_CARET_PRESENCE_KEY` contains the Presence key used by the caret +plugin. In most applications, the plugin manages this Presence value directly. + +## Plugin state + +### LIVEBLOCKS_COLLABORATION_PLUGIN_KEY [#LIVEBLOCKS_COLLABORATION_PLUGIN_KEY] + +The key for reading the collaboration plugin state. `isReady` becomes `true` +after Storage has loaded and the editor has received its initial document. + +```ts +import { LIVEBLOCKS_COLLABORATION_PLUGIN_KEY } from "@liveblocks/prosemirror"; + +const { isReady } = LIVEBLOCKS_COLLABORATION_PLUGIN_KEY.getState( + editorState +) ?? { isReady: false }; +``` + + + + Whether the initial Storage document has been loaded into the editor. + + + +### LIVEBLOCKS_CARET_PLUGIN_KEY [#LIVEBLOCKS_CARET_PLUGIN_KEY] + +The key for reading the collaboration caret plugin state. + +```ts +import { LIVEBLOCKS_CARET_PLUGIN_KEY } from "@liveblocks/prosemirror"; + +const state = LIVEBLOCKS_CARET_PLUGIN_KEY.getState(editorState); +const remoteCursors = state?.cursors ?? []; +``` + + + + The current remote cursor positions and user data. + + + The ProseMirror decorations rendered for remote carets and selections. + + + +## Types + +### ProseMirrorJsonNode [#ProseMirrorJsonNode] + +The JSON representation accepted by the collaboration and conversion APIs. + + + + The ProseMirror node type. + + + The node’s attributes. + + + The node’s children. + + + The content of a text node. + + + The marks applied to a text node. Marks are stored as `LiveText` attributes. + + + +### CursorUser [#CursorUser] + +The user information shown with a remote caret. + + + + The user’s display name. Defaults to `"Anonymous"` when rendered. + + + A CSS color for the user’s caret, label, and selection. Defaults to + `"#0f83ff"`. + + + +### RemoteCursor [#RemoteCursor] + +The cursor data exposed by +[`LIVEBLOCKS_CARET_PLUGIN_KEY`](#LIVEBLOCKS_CARET_PLUGIN_KEY). + + + + The current mapped selection anchor. + + + The current mapped selection head. + + + The Liveblocks connection ID for the remote user. + + + The most recent anchor received through Presence. + + + The most recent head received through Presence. + + + The remote user’s display information. + + + +## Document conversion + +The collaboration plugin automatically converts between ProseMirror JSON and a +Liveblocks Storage tree. Use these helpers only when you need to inspect or +construct that Storage representation directly. + +### createLiveblocksProsemirrorNode [#createLiveblocksProsemirrorNode] + +Converts a ProseMirror JSON node into a +[`LiveObject`](/docs/api-reference/liveblocks-client#LiveObject) tree. Child +nodes are stored in [`LiveList`](/docs/api-reference/liveblocks-client#LiveList) +instances and text leaves are stored in +[`LiveText`](/docs/api-reference/liveblocks-client#LiveText). + +```ts +import { createLiveblocksProsemirrorNode } from "@liveblocks/prosemirror"; + +const document = createLiveblocksProsemirrorNode({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], +}); +``` + + + + The root of the converted Storage tree. + + + + + + The ProseMirror JSON node to convert. + + + +### liveblocksProsemirrorNodeToJson [#liveblocksProsemirrorNodeToJson] + +Converts a `LiveblocksProsemirrorNode` Storage tree back to one ProseMirror JSON +node. + +```ts +import { liveblocksProsemirrorNodeToJson } from "@liveblocks/prosemirror"; + +const json = liveblocksProsemirrorNodeToJson(document, () => ({ + type: "doc", + content: [{ type: "paragraph" }], +})); +``` + + + + The converted ProseMirror JSON node. + + + + + + The Storage node to convert. + + + Returns a document when the converted root document is empty. + + + +### liveblocksProsemirrorNodeToJsonNodes [#liveblocksProsemirrorNodeToJsonNodes] + +Converts a `LiveblocksProsemirrorNode` to an array of ProseMirror JSON nodes. +Formatted `LiveText` segments can produce multiple adjacent text nodes. + +```ts +import { liveblocksProsemirrorNodeToJsonNodes } from "@liveblocks/prosemirror"; + +const nodes = liveblocksProsemirrorNodeToJsonNodes(document); +``` + + + + The converted ProseMirror JSON nodes. + + + + + + The Storage node to convert. + + + +### Storage node helpers [#Storage-node-helpers] + +Use these helpers to read values from a `LiveblocksProsemirrorNode`. + +```ts +import { + getLiveblocksNodeContent, + getLiveblocksNodeId, + getLiveblocksNodeText, +} from "@liveblocks/prosemirror"; + +const id = getLiveblocksNodeId(node); +const children = getLiveblocksNodeContent(node); +const text = getLiveblocksNodeText(node); +``` + + + + Returns the stable ID assigned to the Storage node. + + + Returns the child-node list for a non-text node. + + + Returns the `LiveText` content for a text node. + + diff --git a/docs/pages/api-reference/liveblocks-react-blocknote.mdx b/docs/pages/api-reference/liveblocks-react-blocknote.mdx index b7cdaac6a3a..cf2e8fce095 100644 --- a/docs/pages/api-reference/liveblocks-react-blocknote.mdx +++ b/docs/pages/api-reference/liveblocks-react-blocknote.mdx @@ -846,7 +846,10 @@ const editor = useCreateBlockNoteWithLiveblocks( Options to apply to BlockNote. [Learn more](https://www.blocknotejs.org/docs/editor-basics/setup#usecreateblocknote-hook). - + Options to apply to Liveblocks. @@ -858,6 +861,15 @@ const editor = useCreateBlockNoteWithLiveblocks( one page, if each has a separate field value. [Learn more](#Multiple-editors). + + The collaboration backend to use. `liveblocks` stores editor documents in + `root._tiptap_docs`, keyed by `field`. [Learn + more](#Liveblocks-collaboration-mode). + Object.keys(root._tiptap_docs ?? {}), + shallow + ); + + return fields.map((field) => ); +} +``` + +`offlineSupport_experimental` and `ai` require the default Yjs mode. + #### Offline support [@badge=experimental] It’s possible to enable offline support in your editor with an experimental @@ -1040,8 +1091,9 @@ function Threads({ editor }: { editor: BlockNoteEditor }) { ### useIsEditorReady -Used to check if the editor content has been loaded or not, helpful for -displaying a loading skeleton. +Used to check if the editor content has been loaded or not in the default Yjs +mode, helpful for displaying a loading skeleton. In +`collaborationMode: "liveblocks"`, this hook is usually not needed. ```ts import { useIsEditorReady } from "@liveblocks/react-blocknote"; diff --git a/docs/pages/api-reference/liveblocks-react-lexical.mdx b/docs/pages/api-reference/liveblocks-react-lexical.mdx index 61f3d85c260..7fe388c88fb 100644 --- a/docs/pages/api-reference/liveblocks-react-lexical.mdx +++ b/docs/pages/api-reference/liveblocks-react-lexical.mdx @@ -12,6 +12,13 @@ editor. It also adds realtime cursors, document persistence on the cloud, comments, and mentions. Read our [get started guides](/docs/get-started/text-editor/lexical) to learn more. + + +To sync Lexical with Liveblocks Storage instead (without Comments and mentions), +use [`@liveblocks/lexical`](/docs/api-reference/liveblocks-lexical). + + + ## Setup To set up your collaborative Lexical editor, you must use diff --git a/docs/pages/api-reference/liveblocks-react-tiptap.mdx b/docs/pages/api-reference/liveblocks-react-tiptap.mdx index b51138a452c..9ffadce1246 100644 --- a/docs/pages/api-reference/liveblocks-react-tiptap.mdx +++ b/docs/pages/api-reference/liveblocks-react-tiptap.mdx @@ -10,7 +10,9 @@ alwaysShowAllNavigationLevels: false plugin that adds collaboration to any [Tiptap](https://tiptap.dev/) text editor. It also adds realtime cursors, document persistence on the cloud, comments, and mentions. Read our [get started guides](/docs/get-started/text-editor/tiptap) to -learn more. Use +learn more. For Storage-backed collaboration, see +[Liveblocks collaboration mode](#Liveblocks-collaboration-mode) or the +[Storage get started](/docs/get-started/react-tiptap-storage) guide. Use [`@liveblocks/node-prosemirror`](/docs/api-reference/liveblocks-node-prosemirror) for server-side editing. @@ -1999,6 +2001,16 @@ const liveblocks = useLiveblocksExtension({ one page, if each has a separate field value. [Learn more](#Multiple-editors). + + The collaboration backend to use. `liveblocks` stores editor documents in + `root._tiptap_docs`, keyed by `field`, with text nodes backed by + [`LiveText`](/docs/api-reference/liveblocks-client#LiveText). [Learn + more](#Liveblocks-collaboration-mode). + + + + +
+ ); +} +``` + +Use [`initialContent`](#Setting-initial-content) for the first-load document +when the Storage field is empty. + +You can list editors from Storage by reading `_tiptap_docs` keys: + +```tsx +import { useStorage, shallow } from "@liveblocks/react/suspense"; + +function TextEditors() { + const fields = useStorage( + (root) => Object.keys(root._tiptap_docs ?? {}), + shallow + ); + + return fields.map((field) => ); +} +``` + + + +[`offlineSupport_experimental`](#Offline-support) and [`ai`](#AiToolbar) require +the default Yjs collaboration mode (`collaborationMode: "yjs"`). + + + +In Storage mode, [`useIsEditorReady`](#useIsEditorReady) is usually not +needed—the editor syncs as soon as Storage has loaded. + #### Offline support [@badge=experimental] It’s possible to enable offline support in your editor with an experimental @@ -2260,8 +2363,9 @@ function TextEditor() { ### useIsEditorReady -Used to check if the editor content has been loaded or not, helpful for -displaying a loading skeleton. +Used to check if the editor content has been loaded or not in the default Yjs +mode, helpful for displaying a loading skeleton. In +`collaborationMode: "liveblocks"`, this hook is usually not needed. ```ts import { useIsEditorReady } from "@liveblocks/react-tiptap"; diff --git a/docs/pages/api-reference/liveblocks-react.mdx b/docs/pages/api-reference/liveblocks-react.mdx index bcb6d7ab736..4b81a5bb2c8 100644 --- a/docs/pages/api-reference/liveblocks-react.mdx +++ b/docs/pages/api-reference/liveblocks-react.mdx @@ -3527,7 +3527,7 @@ whiteboard, or cells in a spreadsheet. ### Data structures -Storage provides four data structures which you can use to build your +Storage provides five different data structures, which you can use to build your application. All structures are permanent and persist when all users have left the room, unlike [Presence](/docs/ready-made-features/presence) which is temporary. @@ -3547,6 +3547,11 @@ temporary. by their name. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. +- [`LiveText`][] - A collaborative rich-text document. Use this to store plain + text with optional inline formatting attributes (for example + `{ bold: true }`). Concurrent insertions, deletions, and formatting changes + are merged automatically using server-ordered operational transformation. + - [`LiveFile`](/docs/api-reference/liveblocks-client#LiveFile) - An immutable reference to a file uploaded to Liveblocks. Use this for large files such as images and videos. All file types are supported. Learn more about @@ -3686,6 +3691,202 @@ function App() { } ``` +### LiveText + +[`LiveText`][] is a collaborative rich-text document stored in Storage. Use it +when you need to synchronize plain text with optional inline formatting +attributes across clients—for example, a notes field, a code editor buffer, or +the backing store for a custom text editor. + +#### Typing and initial value + +```ts file="liveblocks.config.ts" +import { LiveText } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Storage: { + document: LiveText; + }; + } +} +``` + +```tsx +import { LiveText } from "@liveblocks/client"; +import { RoomProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + + {/* children */} + + ); +} +``` + +#### Editing with useMutation + +Inside [`useMutation`][], you receive the mutable `LiveText` instance from +Storage. Call [`insert`][], [`delete`][], [`replace`][], or [`format`][] to edit +the document. + +```tsx +import { useMutation } from "@liveblocks/react/suspense"; + +function EditorToolbar() { + const insertText = useMutation(({ storage }) => { + const document = storage.get("document"); + document.insert(document.length, "!"); + }, []); + + const makeBold = useMutation(({ storage }) => { + const document = storage.get("document"); + document.format(0, document.length, { bold: true }); + }, []); + + return ( + <> + + + + ); +} +``` + +`LiveText` edits support [undo and redo](#useUndo) through the room’s history. + +#### Reading with useStorage + +[`useStorage`][] returns an immutable copy of the document as `LiveTextData`—an +array of text segments. Each segment is either a plain string or a +`[text, attributes]` tuple. + +To get the plain text content without attributes, join the segment texts. This +is equivalent to calling [`.toString()`][] on a `LiveText` instance—for example +via `storage.get("document")` inside [`useMutation`][]. + +```tsx +import { useStorage } from "@liveblocks/react/suspense"; + +function DocumentPreview() { + const document = useStorage((root) => root.document); + + if (document === null) { + return null; + } + + // LiveTextData, e.g. [["Hello ", { bold: true }], ["world"]] + const plainText = document.map(([text]) => text).join(""); + // Same result as storage.get("document").toString() + + return

{plainText}

; +} +``` + +#### Rendering formatted text + +Because `useStorage` returns plain data, you can map segments to React elements: + +```tsx +import { useStorage } from "@liveblocks/react/suspense"; + +function FormattedDocument() { + const document = useStorage((root) => root.document); + + if (document === null) { + return null; + } + + return ( +

+ {document.map(([text, attributes], index) => ( + + {text} + + ))} +

+ ); +} +``` + +#### Listening for granular updates + +For custom editor integrations, subscribe to granular `LiveText` changes with +[`useRoom`][] and `room.subscribe` using the `isDeep` option. Each update +includes the change type (`insert`, `delete`, or `format`), index, and affected +text, as well as a +[`source`](/docs/api-reference/liveblocks-client#update-source) saying whether +the change was made by this client or by another one. Learn more about +[listening for nested changes](/docs/api-reference/liveblocks-client#listening-for-nested-changes). + +```tsx +import { LiveText } from "@liveblocks/client"; +import { useRoom } from "@liveblocks/react/suspense"; +import { useEffect } from "react"; + +function LiveTextSync({ document }: { document: LiveText }) { + const room = useRoom(); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + void room.getStorage().then(({ root }) => { + unsubscribe = room.subscribe( + root, + (updates) => { + for (const update of updates) { + if (update.type !== "LiveText" || update.node !== document) { + continue; + } + + for (const change of update.updates) { + if (change.type === "insert") { + // Apply insertion to your local editor at change.index + console.log(`+ "${change.text}" at ${change.index}`); + } else if (change.type === "delete") { + // Apply deletion to your local editor + console.log(`- ${change.length} chars at ${change.index}`); + } + } + } + }, + { isDeep: true } + ); + }); + + return () => unsubscribe?.(); + }, [room, document]); + + return null; +} +``` + +For CodeMirror, use +[`@liveblocks/codemirror`](/docs/api-reference/liveblocks-codemirror) instead of +building a custom sync layer. See +[`createLiveblocksSyncPlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksSyncPlugin) +and +[`createLiveblocksPresencePlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksPresencePlugin). + +For Lexical with Storage, use +[`@liveblocks/lexical`](/docs/api-reference/liveblocks-lexical). + +For ProseMirror with Storage, use +[`@liveblocks/prosemirror`](/docs/api-reference/liveblocks-prosemirror). For +Tiptap, use +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). + +Learn more about the `LiveText` API in the +[`@liveblocks/client` reference](/docs/api-reference/liveblocks-client#LiveText). + Get the [Liveblocks DevTools extension](/devtools) to develop and debug your @@ -7353,6 +7554,13 @@ And the following timeline: [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject +[`livetext`]: /docs/api-reference/liveblocks-client#LiveText +[`insert`]: /docs/api-reference/liveblocks-client#LiveText.insert +[`delete`]: /docs/api-reference/liveblocks-client#LiveText.delete +[`replace`]: /docs/api-reference/liveblocks-client#LiveText.replace +[`format`]: /docs/api-reference/liveblocks-client#LiveText.format +[`useRoom`]: /docs/api-reference/liveblocks-react#useRoom +[`.toString()`]: /docs/api-reference/liveblocks-client#LiveText.toString [`lostconnectiontimeout`]: /docs/api-reference/liveblocks-client#createClientLostConnectionTimeout [`room.history`]: /docs/api-reference/liveblocks-client#Room.history diff --git a/docs/pages/collaboration-features/multiplayer/text-editor/blocknote.mdx b/docs/pages/collaboration-features/multiplayer/text-editor/blocknote.mdx index 62354fc3c05..632477f0bb0 100644 --- a/docs/pages/collaboration-features/multiplayer/text-editor/blocknote.mdx +++ b/docs/pages/collaboration-features/multiplayer/text-editor/blocknote.mdx @@ -211,6 +211,31 @@ function TextEditor() { Learn more about [using multiple editors](/docs/api-reference/liveblocks-react-blocknote#Multiple-editors). +### Liveblocks collaboration mode + +BlockNote uses Yjs-backed collaboration by default. You can opt into +Liveblocks-backed storage instead by passing `collaborationMode: +"liveblocks"`. + +```tsx +import { useCreateBlockNoteWithLiveblocks } from "@liveblocks/react-blocknote"; + +function TextEditor() { + const editor = useCreateBlockNoteWithLiveblocks( + {}, + { + collaborationMode: "liveblocks", + field: "document", + } + ); + + // ... +} +``` + +In this mode, documents are stored in `root._tiptap_docs`, keyed by `field`. +`offlineSupport_experimental` stays Yjs-only. + ### Offline support Liveblocks BlockNote has an experimental option that enables offline support. diff --git a/docs/pages/collaboration-features/multiplayer/text-editor/tiptap.mdx b/docs/pages/collaboration-features/multiplayer/text-editor/tiptap.mdx index 22eb54d6cda..65e9a56bd16 100644 --- a/docs/pages/collaboration-features/multiplayer/text-editor/tiptap.mdx +++ b/docs/pages/collaboration-features/multiplayer/text-editor/tiptap.mdx @@ -309,6 +309,52 @@ function TextEditor() { Learn more about [using multiple editors](/docs/api-reference/liveblocks-react-tiptap#Multiple-editors). +### Liveblocks collaboration mode + +Tiptap uses Yjs-backed collaboration by default. You can opt into +Liveblocks-backed storage instead by passing `collaborationMode: +"liveblocks"`. + +```tsx +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; + +function TextEditor() { + const liveblocks = useLiveblocksExtension({ + collaborationMode: "liveblocks", + field: "document", + initialContent: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], + }, + }); + + // ... +} +``` + +In this mode, documents are stored in `root._tiptap_docs`, keyed by `field`. +This makes it easy to render editors dynamically from storage. + +```tsx +function Editors() { + const fields = useStorage((root) => Object.keys(root._tiptap_docs ?? {})); + + return fields.map((field) => ); +} +``` + +`offlineSupport_experimental` and `ai` stay Yjs-only. Follow the +[React](/docs/get-started/react-tiptap-storage) or +[Next.js](/docs/get-started/nextjs-tiptap-storage) Storage get started guides, or +read the full +[Liveblocks collaboration mode](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) +API reference. + ### Offline support Liveblocks Tiptap has an experimental option that enables offline support. diff --git a/docs/pages/get-started/nextjs-codemirror.mdx b/docs/pages/get-started/nextjs-codemirror.mdx new file mode 100644 index 00000000000..52fc666673d --- /dev/null +++ b/docs/pages/get-started/nextjs-codemirror.mdx @@ -0,0 +1,285 @@ +--- +meta: + title: "Get started with a CodeMirror code editor using Liveblocks and Next.js" + parentTitle: "Quickstart" + description: + "Learn how to install a CodeMirror code editor using Liveblocks and Next.js" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your Next.js application using the APIs from the +[`@liveblocks/codemirror`](/docs/api-reference/liveblocks-codemirror) package. + +## Quickstart + + + + + + Install Liveblocks and CodeMirror + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/codemirror codemirror + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + Add types for your CodeMirror document and presence: + + ```ts file="liveblocks.config.ts" + import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; + import { LiveText } from "@liveblocks/client"; + + declare global { + interface Liveblocks { + Presence: { + selection: LiveblocksCodemirrorSelection | null; + }; + Storage: { + document: LiveText; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } + } + + export {}; + ``` + + + + + + Create a Liveblocks room + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. When using Next.js’ `/app` router, + we recommend creating your room in a `Room.tsx` file in the same directory + as your current route. + + Store your editor document in Storage as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText), and set an initial presence shape for cursors. + + Set up a Liveblocks client with + [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), + join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), + and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) + to add a loading spinner to your app. + + ```tsx file="app/Room.tsx" + "use client"; + + import { ReactNode } from "react"; + import { LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + + export function Room({ children }: { children: ReactNode }) { + return ( + // +++ + + + Loading…}> + {children} + + + + // +++ + ); + } + ``` + + + + + + Add the Liveblocks room to your page + + + After creating your room file, import it into your `page.tsx` file and place + your editor inside it. + + ```tsx file="app/page.tsx" + import { Room } from "./Room"; + import { Editor } from "./Editor"; + + export default function Page() { + return ( + // +++ + + + + // +++ + ); + } + ``` + + + + + + Set up the collaborative CodeMirror editor + + + Now that Liveblocks is set up, create a CodeMirror editor in `Editor.tsx`. + Use [`createLiveblocksSyncPlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksSyncPlugin) + to sync the document with Storage, and + [`createLiveblocksPresencePlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksPresencePlugin) + to show remote carets and selections. + + ```tsx file="app/Editor.tsx" + "use client"; + + import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; + import { EditorView } from "@codemirror/view"; + import { EditorState } from "@codemirror/state"; + import type { LiveText, Room } from "@liveblocks/client"; + import { + createLiveblocksPresencePlugin, + createLiveblocksSyncPlugin, + } from "@liveblocks/codemirror"; + import { useRoom } from "@liveblocks/react/suspense"; + + export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root == null) { + return
Loading…
; + } + + return ; + } + + function EditorInner({ text }: { text: LiveText }) { + const room = useRoom(); + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current === null) return; + + const view = new EditorView({ + parent: containerRef.current, + state: EditorState.create({ + // +++ + doc: text.toString(), + extensions: [ + createLiveblocksSyncPlugin(room, text), + createLiveblocksPresencePlugin(room, text), + ], + // +++ + }), + }); + + return () => { + view.destroy(); + }; + }, [room, text]); + + return
; + } + + function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + ``` + + + + + + Style remote carets and selections + + + The presence plugin renders remote carets and selections using the + `.lb-remote-caret` and `.lb-remote-selection` class names. Add styles for + them in your CSS. This package does not ship a stylesheet. + + ```css file="app/globals.css" + .editor { + height: 100vh; + } + + .lb-remote-selection { + position: absolute; + background-color: color-mix(in srgb, var(--lb-remote-color) 25%, transparent); + border-radius: 1px; + pointer-events: none; + box-sizing: border-box; + } + + .lb-remote-caret { + position: absolute; + width: 0; + border-left: 2px solid var(--lb-remote-color); + pointer-events: none; + box-sizing: border-box; + } + ``` + + ```tsx file="app/layout.tsx" + import "./globals.css"; + ``` + + + + + + Next: authenticate and add your users + + Your editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name and color to remote carets. + + + + + + + + +## What to read next + +Congratulations! You now have set up the foundation for your collaborative +CodeMirror editor inside your Next.js application. + +- [@liveblocks/codemirror API Reference](/docs/api-reference/liveblocks-codemirror) +- [Next.js and React guides](/docs/guides?technologies=nextjs%2Creact) +- [CodeMirror website](https://codemirror.net) + +If you prefer to use Yjs with CodeMirror, see the +[Yjs CodeMirror React quickstart](/docs/get-started/yjs-codemirror-react). diff --git a/docs/pages/get-started/nextjs-lexical-storage.mdx b/docs/pages/get-started/nextjs-lexical-storage.mdx new file mode 100644 index 00000000000..91370b2ad92 --- /dev/null +++ b/docs/pages/get-started/nextjs-lexical-storage.mdx @@ -0,0 +1,274 @@ +--- +meta: + title: + "Get started with a Lexical text editor using Liveblocks Storage and Next.js" + parentTitle: "Quickstart" + description: + "Learn how to sync a Lexical text editor with Liveblocks Storage and Next.js" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your Next.js application using the APIs from the +[`@liveblocks/lexical`](/docs/api-reference/liveblocks-lexical) package. + + + +This guide uses Liveblocks Storage. For Comments, mentions, and the full Text +Editor product, see the +[`@liveblocks/react-lexical` quickstart](/docs/get-started/nextjs-lexical) +instead. + + + +## Quickstart + + + + + + Install Liveblocks and Lexical + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/lexical lexical @lexical/react @lexical/selection @lexical/utils + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + Add types for your Lexical document and presence: + + ```ts file="liveblocks.config.ts" + import type { LiveLexicalSelection, LiveRootNode } from "@liveblocks/lexical"; + + declare global { + interface Liveblocks { + Presence: { + selection: LiveLexicalSelection | null; + }; + Storage: { + document: LiveRootNode; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } + } + + export {}; + ``` + + + + + + Create a Liveblocks room + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. When using Next.js’ `/app` router, + we recommend creating your room in a `Room.tsx` file in the same directory + as your current route. + + Store your editor document as a Storage tree and set an initial presence shape for cursors. + + Set up a Liveblocks client with + [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), + join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), + and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) + to add a loading spinner to your app. + + ```tsx file="app/Room.tsx" + "use client"; + + import { ReactNode } from "react"; + import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + + export function Room({ children }: { children: ReactNode }) { + return ( + // +++ + + + Loading…
}> + {children} + + + + // +++ + ); + } + ``` + +
+ +
+ + Add the Liveblocks room to your page + + + After creating your room file, import it into your `page.tsx` file and place + your editor inside it. + + ```tsx file="app/page.tsx" + import { Room } from "./Room"; + import { Editor } from "./Editor"; + + export default function Page() { + return ( + // +++ + + + + // +++ + ); + } + ``` + + + + + + Set up the collaborative Lexical editor + + + Now that Liveblocks is set up, create a Lexical editor in `Editor.tsx`. + Use [`LiveblocksCollaborationPlugin`](/docs/api-reference/liveblocks-lexical#LiveblocksCollaborationPlugin) + to sync the document with Storage, and + [`RemoteCursorsPlugin`](/docs/api-reference/liveblocks-lexical#RemoteCursorsPlugin) + to show remote carets and selections. + + ```tsx file="app/Editor.tsx" + "use client"; + + import { useCallback, useSyncExternalStore } from "react"; + import { LexicalComposer } from "@lexical/react/LexicalComposer"; + import { ContentEditable } from "@lexical/react/LexicalContentEditable"; + import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; + import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; + import { + LiveblocksCollaborationPlugin, + RemoteCursorsPlugin, + } from "@liveblocks/lexical"; + import type { Room } from "@liveblocks/client"; + import { useRoom } from "@liveblocks/react/suspense"; + import "@liveblocks/lexical/styles.css"; + + export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root === null) { + return
Loading…
; + } + + const document = root.get("document"); + + return ( + console.error(error), + }} + > +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + // +++ + + + + // +++ +
+
+ ); + } + + function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + ``` + +
+ +
+ + Next: authenticate and add your users + + Your editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name and color to remote carets. + + + + + + +
+ +## What to read next + +Congratulations! You now have set up the foundation for your collaborative +Lexical editor inside your Next.js application. + +- [@liveblocks/lexical API Reference](/docs/api-reference/liveblocks-lexical) +- [Next.js and React guides](/docs/guides?technologies=nextjs%2Creact) +- [Lexical website](https://lexical.dev) + +For Comments, mentions, and default Text Editor components, see +[`@liveblocks/react-lexical`](/docs/api-reference/liveblocks-react-lexical). diff --git a/docs/pages/get-started/nextjs-tiptap-storage.mdx b/docs/pages/get-started/nextjs-tiptap-storage.mdx new file mode 100644 index 00000000000..433ae2d3741 --- /dev/null +++ b/docs/pages/get-started/nextjs-tiptap-storage.mdx @@ -0,0 +1,310 @@ +--- +meta: + title: + "Get started with a Tiptap text editor using Liveblocks Storage and Next.js" + parentTitle: "Quickstart" + description: + "Learn how to sync a Tiptap text editor with Liveblocks Storage and Next.js" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your Next.js application using +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). +This stores text as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) +backed by Liveblocks Storage instead of using a Yjs document. + + + +This guide uses Liveblocks Storage. For the default Yjs-backed setup (including +experimental offline support and AI), see the +[standard Tiptap Next.js quickstart](/docs/get-started/nextjs-tiptap). + + + +## Quickstart + + + + + + Install Liveblocks and Tiptap + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-tiptap @tiptap/react @tiptap/starter-kit + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + + + + + Create a Liveblocks room + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. When using Next.js’ `/app` router, + we recommend creating your room in a `Room.tsx` file in the same directory + as your current route. + + In Storage mode, documents are created under `root._tiptap_docs` when the + editor first connects, so you can leave `initialStorage` empty. + + Set up a Liveblocks client with + [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), + join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), + and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) + to add a loading spinner to your app. + + ```tsx file="app/Room.tsx" + "use client"; + + import { ReactNode } from "react"; + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + + export function Room({ children }: { children: ReactNode }) { + return ( + // +++ + + + Loading…}> + {children} + + + + // +++ + ); + } + ``` + + + + + + Add the Liveblocks room to your page + + + After creating your room file, import it into your `page.tsx` file and place + your editor inside it. + + ```tsx file="app/page.tsx" + import { Room } from "./Room"; + import { Editor } from "./Editor"; + + export default function Page() { + return ( + // +++ + + + + // +++ + ); + } + ``` + + + + + + Set up the collaborative Tiptap editor + + + Create a Tiptap editor in `Editor.tsx`. Pass + [`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) + to [`useLiveblocksExtension`](/docs/api-reference/liveblocks-react-tiptap#useLiveblocksExtension) + so the document syncs through Storage. Set a `field` name and optional + [`initialContent`](/docs/api-reference/liveblocks-react-tiptap#Setting-initial-content) + for the first visit. + + ```tsx file="app/Editor.tsx" + "use client"; + + import { + FloatingComposer, + FloatingToolbar, + useLiveblocksExtension, + } from "@liveblocks/react-tiptap"; + import { useEditor, EditorContent } from "@tiptap/react"; + import StarterKit from "@tiptap/starter-kit"; + import { Threads } from "./Threads"; + + const INITIAL_CONTENT = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], + }; + + export function Editor() { + // +++ + const liveblocks = useLiveblocksExtension({ + collaborationMode: "liveblocks", + field: "document", + initialContent: INITIAL_CONTENT, + }); + // +++ + + const editor = useEditor({ + extensions: [ + liveblocks, + StarterKit.configure({ + // The Liveblocks extension comes with its own history handling + undoRedo: false, + }), + ], + immediatelyRender: false, + }); + + return ( +
+ + + + +
+ ); + } + ``` + +
+ +
+ + Render threads + + + Comments work the same as in Yjs mode. Create a `Threads.tsx` file that uses + [`FloatingThreads`](/docs/api-reference/liveblocks-react-tiptap#FloatingThreads) + and [`AnchoredThreads`](/docs/api-reference/liveblocks-react-tiptap#AnchoredThreads). + + ```tsx file="app/Threads.tsx" + import { useThreads } from "@liveblocks/react/suspense"; + import { + AnchoredThreads, + FloatingThreads, + } from "@liveblocks/react-tiptap"; + import { Editor } from "@tiptap/react"; + + export function Threads({ editor }: { editor: Editor | null }) { + const { threads } = useThreads({ query: { resolved: false } }); + + return ( + <> +
+ +
+ + + ); + } + ``` + +
+ +
+ + Style your editor + + + Import Liveblocks styles in your layout, and add basic editor CSS: + + ```tsx file="app/layout.tsx" + import "@liveblocks/react-ui/styles.css"; + import "@liveblocks/react-tiptap/styles.css"; + import "./globals.css"; + ``` + + ```css file="app/globals.css" isCollapsed isCollapsable + .editor { + position: relative; + display: flex; + width: 100%; + height: 100%; + } + + .tiptap { + padding: 2px 12px; + outline: none; + width: 100%; + } + + .floating-threads { + display: none; + } + + .anchored-threads { + display: block; + max-width: 300px; + width: 100%; + position: absolute; + right: 12px; + } + + @media (max-width: 640px) { + .floating-threads { + display: block; + } + + .anchored-threads { + display: none; + } + } + ``` + + + + + + Next: authenticate and add your users + + Text Editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. + + + + + + +
+ +## What to read next + +Congratulations! You now have set up a Storage-backed collaborative Tiptap +editor inside your Next.js application. + +- [Liveblocks collaboration mode](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) +- [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) +- [Yjs-backed Tiptap Next.js quickstart](/docs/get-started/nextjs-tiptap) +- [Tiptap website](https://tiptap.dev) diff --git a/docs/pages/get-started/nextjs-tiptap.mdx b/docs/pages/get-started/nextjs-tiptap.mdx index d3a5bf3e740..6e34e09f844 100644 --- a/docs/pages/get-started/nextjs-tiptap.mdx +++ b/docs/pages/get-started/nextjs-tiptap.mdx @@ -125,6 +125,14 @@ See the finished result in the [Collaborative Text Editor](/examples/collaborati from `@liveblocks/react-tiptap`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-tiptap#FloatingToolbar) adds a text selection toolbar. + By default, `useLiveblocksExtension` uses Yjs for collaboration. To store + text as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) backed + by Liveblocks Storage instead, pass + [`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). + Follow the + [Storage-backed Tiptap Next.js quickstart](/docs/get-started/nextjs-tiptap-storage) + for the complete setup. + ```tsx file="app/Editor.tsx" "use client"; @@ -292,6 +300,7 @@ Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your React application. - [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) +- [Storage-backed Tiptap Next.js quickstart](/docs/get-started/nextjs-tiptap-storage) - [Tiptap guides](/docs/guides?technologies=tiptap) - [Tiptap website](https://tiptap.dev) diff --git a/docs/pages/get-started/react-codemirror.mdx b/docs/pages/get-started/react-codemirror.mdx new file mode 100644 index 00000000000..354c372cdd7 --- /dev/null +++ b/docs/pages/get-started/react-codemirror.mdx @@ -0,0 +1,287 @@ +--- +meta: + title: "Get started with a CodeMirror code editor using Liveblocks and React" + parentTitle: "Quickstart" + description: + "Learn how to install a CodeMirror code editor using Liveblocks and React" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your React application using the APIs from the +[`@liveblocks/codemirror`](/docs/api-reference/liveblocks-codemirror) package. + +## Quickstart + + + + + + Install Liveblocks and CodeMirror + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/codemirror codemirror + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + Add types for your CodeMirror document and presence: + + ```ts file="liveblocks.config.ts" + import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; + import { LiveText } from "@liveblocks/client"; + + declare global { + interface Liveblocks { + Presence: { + selection: LiveblocksCodemirrorSelection | null; + }; + Storage: { + document: LiveText; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } + } + + export {}; + ``` + + + + + + + Set up the Liveblocks client + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). + + Store your editor document in Storage as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText), and set an initial presence shape for cursors. + + ```tsx file="App.tsx" + "use client"; + + import { LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + {/* ... */} + + + ); + } + ``` + + + + + + Join a Liveblocks room + + + After setting up the room, you can add collaborative components inside it, using + [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. + + ```tsx file="App.tsx" + "use client"; + + import { LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + Loading…}> + + + + + ); + } + ``` + + + + + + Set up the collaborative CodeMirror editor + + + Now that Liveblocks is set up, create a CodeMirror editor in `Editor.tsx`. + Use [`createLiveblocksSyncPlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksSyncPlugin) + to sync the document with Storage, and + [`createLiveblocksPresencePlugin`](/docs/api-reference/liveblocks-codemirror#createLiveblocksPresencePlugin) + to show remote carets and selections. + + ```tsx file="Editor.tsx" + "use client"; + + import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; + import { EditorView } from "@codemirror/view"; + import { EditorState } from "@codemirror/state"; + import type { LiveText, Room } from "@liveblocks/client"; + import { + createLiveblocksPresencePlugin, + createLiveblocksSyncPlugin, + } from "@liveblocks/codemirror"; + import { useRoom } from "@liveblocks/react/suspense"; + + export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root == null) { + return
Loading…
; + } + + return ; + } + + function EditorInner({ text }: { text: LiveText }) { + const room = useRoom(); + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current === null) return; + + const view = new EditorView({ + parent: containerRef.current, + state: EditorState.create({ + // +++ + doc: text.toString(), + extensions: [ + createLiveblocksSyncPlugin(room, text), + createLiveblocksPresencePlugin(room, text), + ], + // +++ + }), + }); + + return () => { + view.destroy(); + }; + }, [room, text]); + + return
; + } + + function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + ``` + + + + + + Style remote carets and selections + + + The presence plugin renders remote carets and selections using the + `.lb-remote-caret` and `.lb-remote-selection` class names. Add styles for + them in your CSS. This package does not ship a stylesheet. + + ```css file="globals.css" + .editor { + height: 100%; + } + + .lb-remote-selection { + position: absolute; + background-color: color-mix(in srgb, var(--lb-remote-color) 25%, transparent); + border-radius: 1px; + pointer-events: none; + box-sizing: border-box; + } + + .lb-remote-caret { + position: absolute; + width: 0; + border-left: 2px solid var(--lb-remote-color); + pointer-events: none; + box-sizing: border-box; + } + ``` + + ```tsx file="main.tsx" + import "./globals.css"; + ``` + + + + + + Next: authenticate and add your users + + Your editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name and color to remote carets. + + + + + + + + +## What to read next + +Congratulations! You now have set up the foundation for your collaborative +CodeMirror editor inside your React application. + +- [@liveblocks/codemirror API Reference](/docs/api-reference/liveblocks-codemirror) +- [CodeMirror website](https://codemirror.net) + +If you prefer to use Yjs with CodeMirror, see the +[Yjs CodeMirror React quickstart](/docs/get-started/yjs-codemirror-react). diff --git a/docs/pages/get-started/react-lexical-storage.mdx b/docs/pages/get-started/react-lexical-storage.mdx new file mode 100644 index 00000000000..302ca1c640b --- /dev/null +++ b/docs/pages/get-started/react-lexical-storage.mdx @@ -0,0 +1,297 @@ +--- +meta: + title: + "Get started with a Lexical text editor using Liveblocks Storage and React" + parentTitle: "Quickstart" + description: + "Learn how to sync a Lexical text editor with Liveblocks Storage and React" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your React application using the APIs from the +[`@liveblocks/lexical`](/docs/api-reference/liveblocks-lexical) package. + + + +This guide uses Liveblocks Storage. For Comments, mentions, and the full Text +Editor product, see the +[`@liveblocks/react-lexical` quickstart](/docs/get-started/react-lexical) +instead. + + + +## Quickstart + + + + + + Install Liveblocks and Lexical + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/lexical lexical @lexical/react @lexical/selection @lexical/utils + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + Add types for your Lexical document and presence: + + ```ts file="liveblocks.config.ts" + import type { LiveLexicalSelection, LiveRootNode } from "@liveblocks/lexical"; + + declare global { + interface Liveblocks { + Presence: { + selection: LiveLexicalSelection | null; + }; + Storage: { + document: LiveRootNode; + }; + UserMeta: { + id?: string; + info?: { + name?: string; + color?: string; + }; + }; + } + } + + export {}; + ``` + + + + + + + Set up the Liveblocks client + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). + + Store your editor document as a Storage tree and set an initial presence shape for cursors. + + ```tsx file="App.tsx" + "use client"; + + import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + {/* ... */} + + + ); + } + ``` + + + + + + Join a Liveblocks room + + + After setting up the room, you can add collaborative components inside it, using + [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. + + ```tsx file="App.tsx" + "use client"; + + import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + Loading…
}> + + + + + ); + } + ``` + +
+ +
+ + Set up the collaborative Lexical editor + + + Now that Liveblocks is set up, create a Lexical editor in `Editor.tsx`. + Use [`LiveblocksCollaborationPlugin`](/docs/api-reference/liveblocks-lexical#LiveblocksCollaborationPlugin) + to sync the document with Storage, and + [`RemoteCursorsPlugin`](/docs/api-reference/liveblocks-lexical#RemoteCursorsPlugin) + to show remote carets and selections. + + ```tsx file="Editor.tsx" + "use client"; + + import { useCallback, useSyncExternalStore } from "react"; + import { LexicalComposer } from "@lexical/react/LexicalComposer"; + import { ContentEditable } from "@lexical/react/LexicalContentEditable"; + import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; + import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; + import { + LiveblocksCollaborationPlugin, + RemoteCursorsPlugin, + } from "@liveblocks/lexical"; + import type { Room } from "@liveblocks/client"; + import { useRoom } from "@liveblocks/react/suspense"; + import "@liveblocks/lexical/styles.css"; + + export function Editor() { + const room = useRoom(); + const root = useRoot(room); + + if (root === null) { + return
Loading…
; + } + + const document = root.get("document"); + + return ( + console.error(error), + }} + > +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + // +++ + + + + // +++ +
+
+ ); + } + + function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => null, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + } + ``` + +
+ +
+ + Next: authenticate and add your users + + Your editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name and color to remote carets. + + + + + + +
+ +## What to read next + +Congratulations! You now have set up the foundation for your collaborative +Lexical editor inside your React application. + +- [@liveblocks/lexical API Reference](/docs/api-reference/liveblocks-lexical) +- [Lexical website](https://lexical.dev) + +For Comments, mentions, and default Text Editor components, see +[`@liveblocks/react-lexical`](/docs/api-reference/liveblocks-react-lexical). diff --git a/docs/pages/get-started/react-tiptap-storage.mdx b/docs/pages/get-started/react-tiptap-storage.mdx new file mode 100644 index 00000000000..3defd86e418 --- /dev/null +++ b/docs/pages/get-started/react-tiptap-storage.mdx @@ -0,0 +1,306 @@ +--- +meta: + title: + "Get started with a Tiptap text editor using Liveblocks Storage and React" + parentTitle: "Quickstart" + description: + "Learn how to sync a Tiptap text editor with Liveblocks Storage and React" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start adding +collaboration to your React application using +[`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) with +[`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). +This stores text as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) +backed by Liveblocks Storage instead of using a Yjs document. + + + +This guide uses Liveblocks Storage. For the default Yjs-backed setup (including +experimental offline support and AI), see the +[standard Tiptap React quickstart](/docs/get-started/react-tiptap). + + + +## Quickstart + + + + + + Install Liveblocks and Tiptap + + + Every Liveblocks package should use the same version. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-tiptap @tiptap/react @tiptap/starter-kit + ``` + + + + + + Initialize the `liveblocks.config.ts` file + + + We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). + + ```bash + npx create-liveblocks-app@latest --init --framework react + ``` + + + + + + + Set up the Liveblocks client + + + Liveblocks uses the concept of rooms, separate virtual spaces where people + collaborate, and to create a realtime experience, multiple users must + be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). + + In Storage mode, documents are created under `root._tiptap_docs` when the + editor first connects, so you can leave `initialStorage` empty. + + ```tsx file="App.tsx" + "use client"; + + import { + LiveblocksProvider, + RoomProvider, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + {/* ... */} + + + ); + } + ``` + + + + + + Join a Liveblocks room + + + After setting up the room, you can add collaborative components inside it, using + [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. + + ```tsx file="App.tsx" + "use client"; + + import { + LiveblocksProvider, + RoomProvider, + ClientSideSuspense, + } from "@liveblocks/react/suspense"; + import { Editor } from "./Editor"; + + export default function App() { + return ( + + + Loading…}> + + + + + ); + } + ``` + + + + + + Set up the collaborative Tiptap editor + + + Create a Tiptap editor in `Editor.tsx`. Pass + [`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) + to [`useLiveblocksExtension`](/docs/api-reference/liveblocks-react-tiptap#useLiveblocksExtension) + so the document syncs through Storage. Set a `field` name and optional + [`initialContent`](/docs/api-reference/liveblocks-react-tiptap#Setting-initial-content) + for the first visit. + + ```tsx file="Editor.tsx" + "use client"; + + import { + FloatingComposer, + FloatingToolbar, + useLiveblocksExtension, + } from "@liveblocks/react-tiptap"; + import { useEditor, EditorContent } from "@tiptap/react"; + import StarterKit from "@tiptap/starter-kit"; + import { Threads } from "./Threads"; + + const INITIAL_CONTENT = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], + }; + + export function Editor() { + // +++ + const liveblocks = useLiveblocksExtension({ + collaborationMode: "liveblocks", + field: "document", + initialContent: INITIAL_CONTENT, + }); + // +++ + + const editor = useEditor({ + extensions: [ + liveblocks, + StarterKit.configure({ + // The Liveblocks extension comes with its own history handling + undoRedo: false, + }), + ], + immediatelyRender: false, + }); + + return ( +
+ + + + +
+ ); + } + ``` + +
+ +
+ + Render threads + + + Comments work the same as in Yjs mode. Create a `Threads.tsx` file that uses + [`FloatingThreads`](/docs/api-reference/liveblocks-react-tiptap#FloatingThreads) + and [`AnchoredThreads`](/docs/api-reference/liveblocks-react-tiptap#AnchoredThreads). + + ```tsx file="Threads.tsx" + import { useThreads } from "@liveblocks/react/suspense"; + import { + AnchoredThreads, + FloatingThreads, + } from "@liveblocks/react-tiptap"; + import { Editor } from "@tiptap/react"; + + export function Threads({ editor }: { editor: Editor | null }) { + const { threads } = useThreads({ query: { resolved: false } }); + + return ( + <> +
+ +
+ + + ); + } + ``` + +
+ +
+ + Style your editor + + + Import Liveblocks styles, and add basic editor CSS: + + ```tsx file="main.tsx" + import "@liveblocks/react-ui/styles.css"; + import "@liveblocks/react-tiptap/styles.css"; + import "./globals.css"; + ``` + + ```css file="globals.css" isCollapsed isCollapsable + .editor { + position: relative; + display: flex; + width: 100%; + height: 100%; + } + + .tiptap { + padding: 2px 12px; + outline: none; + width: 100%; + } + + .floating-threads { + display: none; + } + + .anchored-threads { + display: block; + max-width: 300px; + width: 100%; + position: absolute; + right: 12px; + } + + @media (max-width: 640px) { + .floating-threads { + display: block; + } + + .anchored-threads { + display: none; + } + } + ``` + + + + + + Next: authenticate and add your users + + Text Editor is set up and working now, but each user is anonymous—the next step is to + authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. + + + + + + +
+ +## What to read next + +Congratulations! You now have set up a Storage-backed collaborative Tiptap +editor inside your React application. + +- [Liveblocks collaboration mode](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode) +- [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) +- [Yjs-backed Tiptap React quickstart](/docs/get-started/react-tiptap) +- [Tiptap website](https://tiptap.dev) diff --git a/docs/pages/get-started/react-tiptap.mdx b/docs/pages/get-started/react-tiptap.mdx index e1926361b2e..f0ea8c81670 100644 --- a/docs/pages/get-started/react-tiptap.mdx +++ b/docs/pages/get-started/react-tiptap.mdx @@ -118,6 +118,14 @@ package. from `@liveblocks/react-tiptap`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-tiptap#FloatingToolbar) adds a text selection toolbar. + By default, `useLiveblocksExtension` uses Yjs for collaboration. To store + text as [`LiveText`](/docs/api-reference/liveblocks-client#LiveText) backed + by Liveblocks Storage instead, pass + [`collaborationMode: "liveblocks"`](/docs/api-reference/liveblocks-react-tiptap#Liveblocks-collaboration-mode). + Follow the + [Storage-backed Tiptap React quickstart](/docs/get-started/react-tiptap-storage) + for the complete setup. + ```tsx file="Editor.tsx" "use client"; @@ -284,6 +292,7 @@ Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your React application. - [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) +- [Storage-backed Tiptap React quickstart](/docs/get-started/react-tiptap-storage) - [Tiptap guides](/docs/guides?technologies=tiptap) - [Tiptap website](https://tiptap.dev) diff --git a/docs/references/v2.openapi.json b/docs/references/v2.openapi.json index 86f5df93fea..26468df4c9c 100644 --- a/docs/references/v2.openapi.json +++ b/docs/references/v2.openapi.json @@ -1233,7 +1233,7 @@ "/rooms/{roomId}/storage/json-patch": { "patch": { "summary": "Apply JSON Patch to Storage", - "description": "Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any operation fails, the document is not changed and a 422 response with a helpful message is returned.\n\n**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists.\n\n**Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be suitable for this endpoint.\n\nFor a **full guide with examples**, see [Modifying storage via REST API with JSON Patch](https://liveblocks.io/docs/guides/modifying-storage-via-rest-api-with-json-patch).", + "description": "Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any operation fails, the document is not changed and a 422 response with a helpful message is returned.\n\n**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. LiveText is a leaf node: only the LiveText node itself is addressable, not fields under its serialized `data`. Use `replace` with a string or a LiveTextData array to replace the whole node, for example `/text` with `[[\"Hello\"]]`; use `remove` on `/text` to remove the node. LiveText versioning is internal and is not part of this API.\n\n**Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be suitable for this endpoint.\n\nFor a **full guide with examples**, see [Modifying storage via REST API with JSON Patch](https://liveblocks.io/docs/guides/modifying-storage-via-rest-api-with-json-patch).", "tags": ["Storage"], "operationId": "patch-storage-document", "parameters": [ diff --git a/docs/routes.json b/docs/routes.json index 8aa4867dbd8..9bb067afc41 100644 --- a/docs/routes.json +++ b/docs/routes.json @@ -176,11 +176,31 @@ "path": "/get-started/nextjs-tiptap", "hidden": true }, + { + "title": "Tiptap Storage", + "path": "/get-started/react-tiptap-storage", + "hidden": true + }, + { + "title": "Tiptap Storage", + "path": "/get-started/nextjs-tiptap-storage", + "hidden": true + }, { "title": "React Flow", "path": "/get-started/nextjs-react-flow", "hidden": true }, + { + "title": "CodeMirror", + "path": "/get-started/react-codemirror", + "hidden": true + }, + { + "title": "CodeMirror", + "path": "/get-started/nextjs-codemirror", + "hidden": true + }, { "title": "BlockNote", "path": "/get-started/react-blocknote", @@ -201,6 +221,16 @@ "path": "/get-started/nextjs-lexical", "hidden": true }, + { + "title": "Lexical Storage", + "path": "/get-started/react-lexical-storage", + "hidden": true + }, + { + "title": "Lexical Storage", + "path": "/get-started/nextjs-lexical-storage", + "hidden": true + }, { "title": "Tldraw", "path": "/get-started/nextjs-tldraw", @@ -950,6 +980,14 @@ "title": "@liveblocks/react-flow", "path": "/api-reference/liveblocks-react-flow" }, + { + "title": "@liveblocks/codemirror", + "path": "/api-reference/liveblocks-codemirror" + }, + { + "title": "@liveblocks/prosemirror", + "path": "/api-reference/liveblocks-prosemirror" + }, { "title": "@liveblocks/react-tiptap", "path": "/api-reference/liveblocks-react-tiptap" @@ -966,6 +1004,10 @@ "title": "@liveblocks/react-lexical", "path": "/api-reference/liveblocks-react-lexical" }, + { + "title": "@liveblocks/lexical", + "path": "/api-reference/liveblocks-lexical" + }, { "title": "@liveblocks/node-lexical", "path": "/api-reference/liveblocks-node-lexical" diff --git a/e2e/next-ai-kitchen-sink/package.json b/e2e/next-ai-kitchen-sink/package.json index 149c3251c4d..cdb259ef8d6 100644 --- a/e2e/next-ai-kitchen-sink/package.json +++ b/e2e/next-ai-kitchen-sink/package.json @@ -27,11 +27,11 @@ "@eslint/eslintrc": "^3.3.3", "dotenv": "^16.0.0", "@playwright/test": "^1.55.0", - "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/postcss": "^4.2.2", + "tailwindcss": "^4.3.0", "eslint": "^9.39.4", "eslint-config-next": "16.1.4", "playwright": "^1.55.0", - "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } } diff --git a/e2e/next-codemirror-liveblocks/.gitignore b/e2e/next-codemirror-liveblocks/.gitignore new file mode 100644 index 00000000000..ecf213435ce --- /dev/null +++ b/e2e/next-codemirror-liveblocks/.gitignore @@ -0,0 +1,3 @@ +.next/ +.turbo/ +node_modules/ diff --git a/e2e/next-codemirror-liveblocks/app/api/auth/liveblocks/route.ts b/e2e/next-codemirror-liveblocks/app/api/auth/liveblocks/route.ts new file mode 100644 index 00000000000..c848ed07c0c --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/api/auth/liveblocks/route.ts @@ -0,0 +1,51 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextResponse } from "next/server"; + +const USERS = [ + { + id: "codemirror-user-0", + info: { + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + }, + { + id: "codemirror-user-1", + info: { + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "codemirror-user-2", + info: { + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, +]; + +export async function POST() { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, + }); + + const user = USERS[Math.floor(Math.random() * USERS.length)]; + const session = liveblocks.prepareSession(user.id, { + userInfo: user.info, + }); + + session.allow("e2e-codemirror-*", session.FULL_ACCESS); + session.allow("liveblocks:e2e:codemirror:*", session.FULL_ACCESS); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/e2e/next-codemirror-liveblocks/app/globals.css b/e2e/next-codemirror-liveblocks/app/globals.css new file mode 100644 index 00000000000..2fead72be16 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/globals.css @@ -0,0 +1,98 @@ +@import "tailwindcss"; + +.lb-remote-selection { + position: absolute; + background-color: color-mix(in srgb, var(--lb-remote-color) 25%, transparent); + border-radius: 1px; + pointer-events: none; + box-sizing: border-box; +} + +.lb-remote-caret { + position: absolute; + width: 0; + border-left: 2px solid var(--lb-remote-color); + pointer-events: none; + box-sizing: border-box; +} + +.lb-cm-toolbar { + position: absolute; + z-index: 10; + top: 0.75rem; + right: 0.75rem; +} + +.lb-cm-toolbar button { + border: 1px solid rgb(209 213 219); + border-radius: 0.375rem; + background: white; + padding: 0.375rem 0.625rem; + font: inherit; + font-size: 0.875rem; + box-shadow: 0 1px 2px rgb(0 0 0 / 8%); +} + +.lb-cm-thread-mark { + border-bottom: 2px solid rgb(234 179 8); + background-color: rgb(250 204 21 / 32%); + cursor: pointer; +} + +.lb-cm-thread-mark-active { + background-color: rgb(250 204 21 / 48%); +} + +.lb-cm-thread-mark-orphan { + border-bottom-color: rgb(156 163 175); + background-color: rgb(156 163 175 / 18%); +} + +.lb-cm-pending-comment { + background-color: rgb(59 130 246 / 18%); + outline: 1px solid rgb(59 130 246 / 45%); +} + +.lb-cm-floating-composer { + position: fixed; + z-index: 50; + max-height: calc(100vh - 2rem); + overflow: auto; + filter: drop-shadow(0 20px 24px rgb(0 0 0 / 12%)); +} + +.lb-cm-comments-sidebar { + min-height: 0; + overflow: auto; + border-left: 1px solid rgb(229 231 235); + background: rgb(249 250 251); + padding: 1rem; +} + +.lb-cm-comments-sidebar h2 { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; +} + +.lb-cm-comments-empty { + margin: 0; + color: rgb(107 114 128); + font-size: 0.875rem; +} + +.lb-cm-thread-list { + display: grid; + gap: 0.75rem; +} + +.lb-cm-thread-button { + border: 1px solid transparent; + border-radius: 0.5rem; + text-align: left; +} + +.lb-cm-thread-button[data-active] { + border-color: rgb(250 204 21); + box-shadow: 0 0 0 2px rgb(250 204 21 / 20%); +} diff --git a/e2e/next-codemirror-liveblocks/app/layout.tsx b/e2e/next-codemirror-liveblocks/app/layout.tsx new file mode 100644 index 00000000000..45667cfe605 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/layout.tsx @@ -0,0 +1,18 @@ +import "@liveblocks/react-ui/styles.css"; +import "./globals.css"; + +import { Providers } from "./providers"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/e2e/next-codemirror-liveblocks/app/page.tsx b/e2e/next-codemirror-liveblocks/app/page.tsx new file mode 100644 index 00000000000..8f46c0c535a --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { nanoid } from "@liveblocks/client"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Page() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/rooms/e2e-codemirror-${nanoid()}`); + }, [router]); + + return
Creating room...
; +} diff --git a/e2e/next-codemirror-liveblocks/app/providers.tsx b/e2e/next-codemirror-liveblocks/app/providers.tsx new file mode 100644 index 00000000000..1524070e684 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/providers.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; + +const USERS = [ + { + id: "codemirror-user-0", + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + { + id: "codemirror-user-1", + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + { + id: "codemirror-user-2", + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, +]; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + { + const query = text.toLowerCase(); + + return USERS.filter((user) => + user.name.toLowerCase().includes(query) + ).map((user) => user.id); + }} + resolveUsers={async ({ userIds }) => { + return userIds.map((userId) => { + const user = USERS.find((user) => user.id === userId); + + return { + name: user?.name ?? userId, + color: user?.color ?? "#6b7280", + avatar: + user?.avatar ?? "https://liveblocks.io/avatars/avatar-3.png", + }; + }); + }} + throttle={16} + > + {children} + + ); +} diff --git a/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx b/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx new file mode 100644 index 00000000000..7873094fbe6 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { ClientSideSuspense, RoomProvider, useRoom } from "@liveblocks/react"; +import { + createLiveblocksPresencePlugin, + createLiveblocksSyncPlugin, +} from "@liveblocks/codemirror"; +import { + use, + useCallback, + useEffect, + useRef, + useSyncExternalStore, +} from "react"; +import { EditorState } from "@codemirror/state"; +import { Room } from "@liveblocks/client"; +import { LiveText } from "@liveblocks/core"; +import { EditorView } from "@codemirror/view"; + +export default function RoomPage({ + params, +}: { + params: Promise<{ roomId: string }>; +}) { + const { roomId } = use(params); + + return ( + + Connecting to room…}> +
+ +
+
+
+ ); +} + +function Editor() { + const room = useRoom(); + const root = useRoot(room); + if (root == null) { + return
Loading room data…
; + } + + return ; +} + +function EditorInner({ text }: { text: LiveText }) { + const room = useRoom(); + const container = useRef(null); + const view = useRef(null); + + useEffect(() => { + if (container.current === null) return; + + const _view = new EditorView({ + parent: container.current, + state: EditorState.create({ + doc: text.toString(), + extensions: [ + createLiveblocksSyncPlugin(room, text), + createLiveblocksPresencePlugin(room, text), + ], + }), + }); + + view.current = _view; + return () => { + _view.destroy(); + view.current = null; + }; + }, []); + + return ( +
+
+
+ ); +} + +function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => { + return null; + }, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/e2e/next-codemirror-liveblocks/eslint.config.mjs b/e2e/next-codemirror-liveblocks/eslint.config.mjs new file mode 100644 index 00000000000..348c45a2fd8 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/e2e/next-codemirror-liveblocks/liveblocks.config.ts b/e2e/next-codemirror-liveblocks/liveblocks.config.ts new file mode 100644 index 00000000000..f7c4ce29f28 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/liveblocks.config.ts @@ -0,0 +1,25 @@ +import type { LiveblocksCodemirrorSelection } from "@liveblocks/codemirror"; +import type { LiveText } from "@liveblocks/core"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveblocksCodemirrorSelection | null; + }; + + Storage: { + document: LiveText; + }; + + UserMeta: { + id: string; + info: { + name: string; + color: string; + avatar: string; + }; + }; + } +} + +export {}; diff --git a/e2e/next-codemirror-liveblocks/next-env.d.ts b/e2e/next-codemirror-liveblocks/next-env.d.ts new file mode 100644 index 00000000000..9edff1c7cac --- /dev/null +++ b/e2e/next-codemirror-liveblocks/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/e2e/next-codemirror-liveblocks/next.config.ts b/e2e/next-codemirror-liveblocks/next.config.ts new file mode 100644 index 00000000000..92ad41bb8f0 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/next.config.ts @@ -0,0 +1,10 @@ +import path from "path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + turbopack: { + root: path.join(__dirname, "../.."), + }, +}; + +export default nextConfig; diff --git a/e2e/next-codemirror-liveblocks/package.json b/e2e/next-codemirror-liveblocks/package.json new file mode 100644 index 00000000000..adcee96dc2c --- /dev/null +++ b/e2e/next-codemirror-liveblocks/package.json @@ -0,0 +1,40 @@ +{ + "name": "@liveblocks/next-codemirror-liveblocks", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3009", + "build": "next build", + "start": "next start --port 3009", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", + "@liveblocks/client": "workspace:*", + "@liveblocks/codemirror": "workspace:*", + "@liveblocks/node": "workspace:*", + "@liveblocks/react": "workspace:*", + "@liveblocks/react-ui": "workspace:*", + "next": "16.1.4", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@liveblocks/core": "workspace:*", + "@liveblocks/vitest-config": "workspace:*", + "@eslint/eslintrc": "^3.3.3", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^24.12.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "eslint": "^9.39.4", + "eslint-config-next": "16.1.4", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + } +} diff --git a/e2e/next-codemirror-liveblocks/postcss.config.mjs b/e2e/next-codemirror-liveblocks/postcss.config.mjs new file mode 100644 index 00000000000..c7bcb4b1ee1 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/e2e/next-codemirror-liveblocks/tsconfig.json b/e2e/next-codemirror-liveblocks/tsconfig.json new file mode 100644 index 00000000000..27f69a74d9c --- /dev/null +++ b/e2e/next-codemirror-liveblocks/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/e2e/next-codemirror-liveblocks/turbo.json b/e2e/next-codemirror-liveblocks/turbo.json new file mode 100644 index 00000000000..d67cb020b1e --- /dev/null +++ b/e2e/next-codemirror-liveblocks/turbo.json @@ -0,0 +1,17 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["build"], + "cache": false + }, + "test:ci": { + "dependsOn": ["build"], + "cache": false + }, + "test:ui": { + "dependsOn": ["build"], + "cache": false + } + } +} diff --git a/e2e/next-codemirror-liveblocks/vitest.config.ts b/e2e/next-codemirror-liveblocks/vitest.config.ts new file mode 100644 index 00000000000..a700fd20c60 --- /dev/null +++ b/e2e/next-codemirror-liveblocks/vitest.config.ts @@ -0,0 +1,8 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "happy-dom", + include: ["app/**/*.test.ts"], + }, +}); diff --git a/e2e/next-lexical-liveblocks/.gitignore b/e2e/next-lexical-liveblocks/.gitignore new file mode 100644 index 00000000000..ecf213435ce --- /dev/null +++ b/e2e/next-lexical-liveblocks/.gitignore @@ -0,0 +1,3 @@ +.next/ +.turbo/ +node_modules/ diff --git a/e2e/next-lexical-liveblocks/app/api/auth/liveblocks/route.ts b/e2e/next-lexical-liveblocks/app/api/auth/liveblocks/route.ts new file mode 100644 index 00000000000..e0af566f9b6 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/api/auth/liveblocks/route.ts @@ -0,0 +1,51 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextResponse } from "next/server"; + +const USERS = [ + { + id: "lexical-user-0", + info: { + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + }, + { + id: "lexical-user-1", + info: { + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "lexical-user-2", + info: { + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, +]; + +export async function POST() { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, + }); + + const user = USERS[Math.floor(Math.random() * USERS.length)]; + const session = liveblocks.prepareSession(user.id, { + userInfo: user.info, + }); + + session.allow("e2e-lexical-*", session.FULL_ACCESS); + session.allow("liveblocks:e2e:lexical:*", session.FULL_ACCESS); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/e2e/next-lexical-liveblocks/app/globals.css b/e2e/next-lexical-liveblocks/app/globals.css new file mode 100644 index 00000000000..f1d8c73cdcf --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/e2e/next-lexical-liveblocks/app/layout.tsx b/e2e/next-lexical-liveblocks/app/layout.tsx new file mode 100644 index 00000000000..4881b6541c7 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/layout.tsx @@ -0,0 +1,19 @@ +import "@liveblocks/lexical/styles.css"; +import "@liveblocks/react-ui/styles.css"; +import "./globals.css"; + +import { Providers } from "./providers"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/e2e/next-lexical-liveblocks/app/page.tsx b/e2e/next-lexical-liveblocks/app/page.tsx new file mode 100644 index 00000000000..116ade7caf1 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { nanoid } from "@liveblocks/client"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Page() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/rooms/e2e-lexical-${nanoid()}`); + }, [router]); + + return
Creating room...
; +} diff --git a/e2e/next-lexical-liveblocks/app/providers.tsx b/e2e/next-lexical-liveblocks/app/providers.tsx new file mode 100644 index 00000000000..7dd27b667b9 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/providers.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; + +const USERS = [ + { + id: "lexical-user-0", + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + { + id: "lexical-user-1", + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + { + id: "lexical-user-2", + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, +]; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + { + const query = text.toLowerCase(); + + return USERS.filter((user) => + user.name.toLowerCase().includes(query) + ).map((user) => user.id); + }} + resolveUsers={async ({ userIds }) => { + return userIds.map((userId) => { + const user = USERS.find((user) => user.id === userId); + + return { + name: user?.name ?? userId, + color: user?.color ?? "#6b7280", + avatar: + user?.avatar ?? "https://liveblocks.io/avatars/avatar-3.png", + }; + }); + }} + throttle={16} + > + {children} + + ); +} diff --git a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/ImageNode.tsx b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/ImageNode.tsx new file mode 100644 index 00000000000..2a0150957c6 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/ImageNode.tsx @@ -0,0 +1,213 @@ +"use client"; + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + LexicalUpdateJSON, + NodeKey, + SerializedLexicalNode, + Spread, +} from "lexical"; +import { $applyNodeReplacement, DecoratorNode } from "lexical"; +import type { JSX } from "react"; + +export type ImagePayload = { + altText: string; + src: string; + height?: number; + width?: number; + key?: NodeKey; +}; + +export type SerializedImageNode = Spread< + { + altText: string; + src: string; + height?: number; + width?: number; + }, + SerializedLexicalNode +>; + +function $convertImageElement(domNode: Node): DOMConversionOutput | null { + if (!(domNode instanceof HTMLImageElement)) { + return null; + } + + const { alt: altText, src, width, height } = domNode; + return { + node: $createImageNode({ + altText, + src, + width: width || undefined, + height: height || undefined, + }), + }; +} + +function ImageComponent({ + src, + altText, + width, + height, +}: { + src: string; + altText: string; + width?: number; + height?: number; +}) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {altText} + ); +} + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: number | undefined; + __height: number | undefined; + + static getType(): string { + return "image"; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode( + node.__src, + node.__altText, + node.__width, + node.__height, + node.__key + ); + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { altText, src, width, height } = serializedNode; + return $createImageNode({ altText, src, width, height }).updateFromJSON( + serializedNode + ); + } + + static importDOM(): DOMConversionMap | null { + return { + img: () => ({ + conversion: $convertImageElement, + priority: 0, + }), + }; + } + + constructor( + src: string, + altText: string, + width?: number, + height?: number, + key?: NodeKey + ) { + super(key); + this.__src = src; + this.__altText = altText; + this.__width = width; + this.__height = height; + } + + exportJSON(): SerializedImageNode { + return { + ...super.exportJSON(), + altText: this.__altText, + src: this.__src, + width: this.__width, + height: this.__height, + }; + } + + updateFromJSON(serializedNode: LexicalUpdateJSON): this { + const node = super.updateFromJSON(serializedNode); + const writable = node.getWritable(); + if (serializedNode.src !== undefined) { + writable.__src = serializedNode.src; + } + if (serializedNode.altText !== undefined) { + writable.__altText = serializedNode.altText; + } + if ("width" in serializedNode) { + writable.__width = serializedNode.width; + } + if ("height" in serializedNode) { + writable.__height = serializedNode.height; + } + return writable; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement("img"); + element.setAttribute("src", this.__src); + element.setAttribute("alt", this.__altText); + if (this.__width !== undefined) { + element.setAttribute("width", String(this.__width)); + } + if (this.__height !== undefined) { + element.setAttribute("height", String(this.__height)); + } + return { element }; + } + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement("span"); + const theme = config.theme; + if (theme.image !== undefined) { + span.className = theme.image; + } + return span; + } + + updateDOM(): false { + return false; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } + + decorate(): JSX.Element { + return ( + + ); + } +} + +export function $createImageNode({ + altText, + src, + width, + height, + key, +}: ImagePayload): ImageNode { + return $applyNodeReplacement(new ImageNode(src, altText, width, height, key)); +} + +export function $isImageNode( + node: LexicalNode | null | undefined +): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/MentionNode.ts b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/MentionNode.ts new file mode 100644 index 00000000000..d450592cc3d --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/nodes/MentionNode.ts @@ -0,0 +1,94 @@ +import { + $applyNodeReplacement, + type DOMExportOutput, + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedTextNode, + type Spread, + TextNode, +} from "lexical"; + +export type SerializedMentionNode = Spread< + { + mentionName: string; + }, + SerializedTextNode +>; + +export class MentionNode extends TextNode { + __mention: string; + + static getType(): string { + return "mention"; + } + + static clone(node: MentionNode): MentionNode { + return new MentionNode(node.__mention, node.__text, node.__key); + } + + static importJSON(serializedNode: SerializedMentionNode): MentionNode { + return $createMentionNode(serializedNode.mentionName).updateFromJSON( + serializedNode + ); + } + + constructor(mentionName: string, text?: string, key?: NodeKey) { + super(text ?? mentionName, key); + this.__mention = mentionName; + } + + exportJSON(): SerializedMentionNode { + return { + ...super.exportJSON(), + mentionName: this.__mention, + }; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const className = config.theme.mention; + if (className !== undefined) { + dom.className = className; + } + dom.spellcheck = false; + return dom; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement("span"); + element.setAttribute("data-lexical-mention", "true"); + if (this.__text !== this.__mention) { + element.setAttribute("data-lexical-mention-name", this.__mention); + } + element.textContent = this.__text; + return { element }; + } + + isTextEntity(): true { + return true; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } +} + +export function $createMentionNode( + mentionName: string, + textContent?: string +): MentionNode { + const mentionNode = new MentionNode(mentionName, textContent ?? mentionName); + mentionNode.setMode("segmented").toggleDirectionless(); + return $applyNodeReplacement(mentionNode); +} + +export function $isMentionNode( + node: LexicalNode | null | undefined +): node is MentionNode { + return node instanceof MentionNode; +} diff --git a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx new file mode 100644 index 00000000000..713b9f2a941 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { + $createHorizontalRuleNode, + HorizontalRuleNode, + INSERT_HORIZONTAL_RULE_COMMAND, +} from "@lexical/extension"; +import { HashtagNode } from "@lexical/hashtag"; +import { + AutoLinkNode, + LinkNode, + createLinkMatcherWithRegExp, +} from "@lexical/link"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { MarkNode } from "@lexical/mark"; +import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { TablePlugin } from "@lexical/react/LexicalTablePlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; +import { $insertNodeToNearestRoot } from "@lexical/utils"; +import { LiveList, LiveObject, LiveText, type Room } from "@liveblocks/client"; +import { + LiveblocksCollaborationPlugin, + RemoteCursorsPlugin, +} from "@liveblocks/lexical"; +import { ClientSideSuspense, RoomProvider, useRoom } from "@liveblocks/react"; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, +} from "lexical"; +import { use, useCallback, useEffect, useSyncExternalStore } from "react"; + +import { ImageNode } from "./nodes/ImageNode"; +import { MentionNode } from "./nodes/MentionNode"; +import { Toolbar } from "./toolbar"; + +const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + +const AUTO_LINK_MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => + text.startsWith("http") ? text : `https://${text}` + ), + createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => `mailto:${text}`), +]; + +export default function RoomPage({ + params, +}: { + params: Promise<{ roomId: string }>; +}) { + const { roomId } = use(params); + + return ( + + Connecting to room…
}> +
+ +
+ + + ); +} + +function Editor() { + const room = useRoom(); + const root = useRoot(room); + if (root === null) { + return ( +
+ Loading room data… +
+ ); + } + + const document = root.get("document"); + + return ( + { + console.error(error); + }, + }} + > + +
+ + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + +
+
+ ); +} + +function HorizontalRulePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + INSERT_HORIZONTAL_RULE_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + $insertNodeToNearestRoot($createHorizontalRuleNode()); + return true; + }, + COMMAND_PRIORITY_EDITOR + ); + }, [editor]); + + return null; +} + +function useRoot(room: Room) { + const subscribe = room.events.storageDidLoad.subscribeOnce; + const getSnapshot = room.getStorageOrNull; + const getServerSnapshot = useCallback(() => { + return null; + }, []); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +const THEME = { + text: { + bold: "font-bold", + italic: "italic", + underline: "underline", + strikethrough: "line-through", + code: "rounded bg-neutral-100 p-1 font-mono text-sm dark:bg-neutral-800", + }, + quote: "mb-4 border-l-4 border-neutral-300 pl-4 dark:border-neutral-700", + heading: { + h1: "mb-4 text-4xl font-bold", + h2: "mb-4 text-3xl font-bold", + h3: "mb-4 text-2xl font-bold", + h4: "mb-4 text-xl font-bold", + h5: "mb-4 text-lg font-bold", + h6: "mb-4 text-base font-bold", + }, + paragraph: "mb-4 text-base", + link: "pointer-events-none text-blue-500 underline after:pointer-events-auto after:cursor-pointer after:font-bold after:content-['↗']", + list: { + ul: "mb-4 list-disc", + ol: "mb-4 list-decimal", + listitem: "mb-1 ml-4", + }, + code: "mb-4 block overflow-x-auto rounded bg-neutral-100 px-4 py-2 font-mono text-sm dark:bg-neutral-800", + codeHighlight: { + atrule: "text-purple-700 dark:text-purple-300", + attr: "text-purple-700 dark:text-purple-300", + boolean: "text-amber-700 dark:text-amber-300", + builtin: "text-emerald-700 dark:text-emerald-300", + cdata: "text-neutral-500", + char: "text-emerald-700 dark:text-emerald-300", + class: "text-sky-700 dark:text-sky-300", + "class-name": "text-sky-700 dark:text-sky-300", + comment: "text-neutral-500 italic", + constant: "text-amber-700 dark:text-amber-300", + deleted: "text-red-600", + doctype: "text-neutral-500", + entity: "text-orange-700 dark:text-orange-300", + function: "text-sky-700 dark:text-sky-300", + important: "text-rose-700 dark:text-rose-300", + inserted: "text-emerald-700", + keyword: "text-purple-700 dark:text-purple-300", + namespace: "text-rose-700 dark:text-rose-300", + number: "text-amber-700 dark:text-amber-300", + operator: "text-orange-700 dark:text-orange-300", + prolog: "text-neutral-500", + property: "text-amber-700 dark:text-amber-300", + punctuation: "text-neutral-600 dark:text-neutral-300", + regex: "text-rose-700 dark:text-rose-300", + selector: "text-emerald-700 dark:text-emerald-300", + string: "text-emerald-700 dark:text-emerald-300", + symbol: "text-amber-700 dark:text-amber-300", + tag: "text-amber-700 dark:text-amber-300", + url: "text-orange-700 dark:text-orange-300", + variable: "text-rose-700 dark:text-rose-300", + }, + hr: "my-6 border-0 border-t border-neutral-300 dark:border-neutral-700", + hrSelected: "outline outline-2 outline-offset-2 outline-blue-500", + table: "mb-4 w-full table-fixed border-collapse", + tableCell: + "relative border border-neutral-300 p-2 align-top dark:border-neutral-700", + tableCellHeader: "bg-neutral-100 font-semibold dark:bg-neutral-900", + tableSelected: "outline outline-2 outline-blue-500", + tableCellSelected: "bg-blue-50 dark:bg-blue-950", + mark: "rounded bg-yellow-200 dark:bg-yellow-800", + markOverlap: "rounded bg-yellow-300 dark:bg-yellow-700", + hashtag: "text-blue-600 dark:text-blue-400", + mention: + "rounded bg-blue-100 px-0.5 text-blue-700 dark:bg-blue-950 dark:text-blue-300", + image: "inline-block", +}; diff --git a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/toolbar.tsx b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/toolbar.tsx new file mode 100644 index 00000000000..0ac806e9d04 --- /dev/null +++ b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/toolbar.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { $createCodeNode, $isCodeNode } from "@lexical/code"; +import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension"; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + ListNode, + REMOVE_LIST_COMMAND, +} from "@lexical/list"; +import { $wrapSelectionInMarkNode } from "@lexical/mark"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + $isQuoteNode, + type HeadingTagType, +} from "@lexical/rich-text"; +import { $setBlocksType } from "@lexical/selection"; +import { INSERT_TABLE_COMMAND } from "@lexical/table"; +import { + $findMatchingParent, + $getNearestNodeOfType, + $insertNodeToNearestRoot, + mergeRegister, +} from "@lexical/utils"; +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + type TextFormatType, +} from "lexical"; +import { useCallback, useEffect, useState } from "react"; + +import { $createImageNode } from "./nodes/ImageNode"; +import { $createMentionNode } from "./nodes/MentionNode"; + +export function Toolbar() { + const [editor] = useLexicalComposerContext(); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isLink, setIsLink] = useState(false); + const [blockType, setBlockType] = useState("paragraph"); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + + const anchorNode = selection.anchor.getNode(); + const parent = anchorNode.getParent(); + setIsLink($isLinkNode(parent) || $isLinkNode(anchorNode)); + + const heading = $findMatchingParent(anchorNode, $isHeadingNode); + if ($isHeadingNode(heading)) { + setBlockType(heading.getTag()); + return; + } + + const list = $getNearestNodeOfType(anchorNode, ListNode); + if (list !== null) { + setBlockType(list.getListType()); + return; + } + + const quote = $findMatchingParent(anchorNode, $isQuoteNode); + if ($isQuoteNode(quote)) { + setBlockType("quote"); + return; + } + + const code = $findMatchingParent(anchorNode, $isCodeNode); + if ($isCodeNode(code)) { + setBlockType("code"); + return; + } + + const element = + anchorNode.getKey() === "root" + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + setBlockType(element.getType()); + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(updateToolbar); + }), + editor.registerEditableListener(() => { + editor.getEditorState().read(updateToolbar); + }) + ); + }, [editor, updateToolbar]); + + const formatText = (format: TextFormatType) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }; + + const formatParagraph = () => { + if (blockType === "bullet" || blockType === "number") { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + return; + } + + editor.update(() => { + $setBlocksType($getSelection(), () => $createParagraphNode()); + }); + }; + + const formatHeading = (tag: HeadingTagType) => { + if (blockType === tag) { + formatParagraph(); + return; + } + + editor.update(() => { + $setBlocksType($getSelection(), () => $createHeadingNode(tag)); + }); + }; + + const formatQuote = () => { + if (blockType === "quote") { + formatParagraph(); + return; + } + + editor.update(() => { + $setBlocksType($getSelection(), () => $createQuoteNode()); + }); + }; + + const formatBulletList = () => { + if (blockType === "bullet") { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + return; + } + + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + + const formatNumberedList = () => { + if (blockType === "number") { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + return; + } + + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + }; + + const formatCode = () => { + if (blockType === "code") { + formatParagraph(); + return; + } + + editor.update(() => { + $setBlocksType($getSelection(), () => $createCodeNode()); + }); + }; + + const formatLink = () => { + if (isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + return; + } + + const url = window.prompt("Enter URL", "https://"); + if (url === null || url.trim() === "") { + return; + } + + editor.dispatchCommand(TOGGLE_LINK_COMMAND, url.trim()); + }; + + const insertHorizontalRule = () => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); + }; + + const insertTable = () => { + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: "3", + rows: "3", + includeHeaders: { rows: true, columns: false }, + }); + }; + + const insertImage = () => { + editor.update(() => { + $insertNodeToNearestRoot( + $createImageNode({ + src: "https://placehold.co/600x320/png?text=Image", + altText: "Image", + }) + ); + }); + }; + + const insertMention = () => { + const name = window.prompt("Mention name", "alice"); + if (name === null || name.trim() === "") { + return; + } + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + const mention = $createMentionNode(name.trim()); + selection.insertNodes([mention, $createTextNode(" ")]); + }); + }; + + const formatMark = () => { + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return; + } + + $wrapSelectionInMarkNode(selection, selection.isBackward(), "mark"); + }); + }; + + return ( +
+ + formatHeading("h1")} + /> + formatHeading("h2")} + /> + formatHeading("h3")} + /> + + + + + + + + + | + + formatText("bold")} + /> + formatText("italic")} + /> + formatText("underline")} + /> + + + +
+ ); +} + +function ToolbarButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/e2e/next-lexical-liveblocks/eslint.config.mjs b/e2e/next-lexical-liveblocks/eslint.config.mjs new file mode 100644 index 00000000000..348c45a2fd8 --- /dev/null +++ b/e2e/next-lexical-liveblocks/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/e2e/next-lexical-liveblocks/liveblocks.config.ts b/e2e/next-lexical-liveblocks/liveblocks.config.ts new file mode 100644 index 00000000000..8f4d7b8691b --- /dev/null +++ b/e2e/next-lexical-liveblocks/liveblocks.config.ts @@ -0,0 +1,24 @@ +import type { LiveLexicalSelection, LiveRootNode } from "@liveblocks/lexical"; + +declare global { + interface Liveblocks { + Presence: { + selection: LiveLexicalSelection | null; + }; + + Storage: { + document: LiveRootNode; + }; + + UserMeta: { + id: string; + info: { + name: string; + color: string; + avatar: string; + }; + }; + } +} + +export {}; diff --git a/e2e/next-lexical-liveblocks/next-env.d.ts b/e2e/next-lexical-liveblocks/next-env.d.ts new file mode 100644 index 00000000000..9edff1c7cac --- /dev/null +++ b/e2e/next-lexical-liveblocks/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/e2e/next-lexical-liveblocks/next.config.ts b/e2e/next-lexical-liveblocks/next.config.ts new file mode 100644 index 00000000000..ccc38513892 --- /dev/null +++ b/e2e/next-lexical-liveblocks/next.config.ts @@ -0,0 +1,11 @@ +import path from "path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@liveblocks/lexical"], + turbopack: { + root: path.join(__dirname, "../.."), + }, +}; + +export default nextConfig; diff --git a/e2e/next-lexical-liveblocks/package.json b/e2e/next-lexical-liveblocks/package.json new file mode 100644 index 00000000000..1836303332c --- /dev/null +++ b/e2e/next-lexical-liveblocks/package.json @@ -0,0 +1,54 @@ +{ + "name": "@liveblocks/next-lexical-liveblocks", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3009", + "build": "next build", + "start": "next start --port 3009", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@lexical/code": "0.45.0", + "@lexical/dragon": "0.45.0", + "@lexical/extension": "0.45.0", + "@lexical/hashtag": "0.45.0", + "@lexical/link": "0.45.0", + "@lexical/list": "0.45.0", + "@lexical/mark": "0.45.0", + "@lexical/markdown": "0.45.0", + "@lexical/react": "0.45.0", + "@lexical/rich-text": "0.45.0", + "@lexical/selection": "0.45.0", + "@lexical/table": "0.45.0", + "@lexical/text": "0.45.0", + "@lexical/utils": "0.45.0", + "@liveblocks/client": "workspace:*", + "@liveblocks/core": "workspace:*", + "@liveblocks/lexical": "workspace:*", + "@liveblocks/node": "workspace:*", + "@liveblocks/react": "workspace:*", + "@liveblocks/react-ui": "workspace:*", + "lexical": "0.45.0", + "next": "16.1.4", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.3", + "@lexical-devtools/react": "^0.1.11", + "@liveblocks/core": "workspace:*", + "@liveblocks/vitest-config": "workspace:*", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^24.12.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "eslint": "^9.39.4", + "eslint-config-next": "16.1.4", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + } +} diff --git a/e2e/next-lexical-liveblocks/postcss.config.mjs b/e2e/next-lexical-liveblocks/postcss.config.mjs new file mode 100644 index 00000000000..c7bcb4b1ee1 --- /dev/null +++ b/e2e/next-lexical-liveblocks/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/e2e/next-lexical-liveblocks/tsconfig.json b/e2e/next-lexical-liveblocks/tsconfig.json new file mode 100644 index 00000000000..27f69a74d9c --- /dev/null +++ b/e2e/next-lexical-liveblocks/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/e2e/next-lexical-liveblocks/turbo.json b/e2e/next-lexical-liveblocks/turbo.json new file mode 100644 index 00000000000..d67cb020b1e --- /dev/null +++ b/e2e/next-lexical-liveblocks/turbo.json @@ -0,0 +1,17 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["build"], + "cache": false + }, + "test:ci": { + "dependsOn": ["build"], + "cache": false + }, + "test:ui": { + "dependsOn": ["build"], + "cache": false + } + } +} diff --git a/e2e/next-lexical-liveblocks/vitest.config.ts b/e2e/next-lexical-liveblocks/vitest.config.ts new file mode 100644 index 00000000000..a700fd20c60 --- /dev/null +++ b/e2e/next-lexical-liveblocks/vitest.config.ts @@ -0,0 +1,8 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "happy-dom", + include: ["app/**/*.test.ts"], + }, +}); diff --git a/e2e/next-react-flow-kitchen-sink/package.json b/e2e/next-react-flow-kitchen-sink/package.json index eb2467c0a41..0813a6415b1 100644 --- a/e2e/next-react-flow-kitchen-sink/package.json +++ b/e2e/next-react-flow-kitchen-sink/package.json @@ -22,10 +22,10 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/postcss": "^4.2.2", + "tailwindcss": "^4.3.0", "eslint": "^9.39.4", "eslint-config-next": "16.1.4", - "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } } diff --git a/e2e/next-sandbox/pages/index.tsx b/e2e/next-sandbox/pages/index.tsx index 9f4992435ce..e08038ad0ae 100644 --- a/e2e/next-sandbox/pages/index.tsx +++ b/e2e/next-sandbox/pages/index.tsx @@ -43,6 +43,11 @@ export default function Home() {
  • Stress Test
  • +
  • + + LiveText + +
  • diff --git a/e2e/next-sandbox/pages/storage/text.tsx b/e2e/next-sandbox/pages/storage/text.tsx new file mode 100644 index 00000000000..c973bb2badf --- /dev/null +++ b/e2e/next-sandbox/pages/storage/text.tsx @@ -0,0 +1,196 @@ +import { LiveText } from "@liveblocks/client"; +import { createRoomContext } from "@liveblocks/react"; + +import { getRoomFromUrl, Row, styles, useRenderCount } from "../../utils"; +import Button from "../../utils/Button"; +import { createLiveblocksClient } from "../../utils/createClient"; + +const client = createLiveblocksClient(); + +const { + RoomProvider, + useCanRedo, + useCanUndo, + useMutation, + useRedo, + useRoom, + useSelf, + useStatus, + useStorage, + useSyncStatus, + useUndo, +} = createRoomContext(client); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Incremental edits that walk "Hello, world!" → "Lorem ipsum dolor sit amet.", +// one op per step, so an offline client has many intervening versions to rebase +// against when it reconnects. +const MORPH_STEPS: ((text: LiveText) => void)[] = [ + (text) => text.replace(0, 5, "Lorem"), // "Lorem, world!" + (text) => text.replace(5, 2, " "), // "Lorem world!" + (text) => text.replace(6, 6, "ipsum"), // "Lorem ipsum" + (text) => text.insert(11, " dolor"), // "Lorem ipsum dolor" + (text) => text.insert(17, " sit"), // "Lorem ipsum dolor sit" + (text) => text.insert(21, " amet"), // "Lorem ipsum dolor sit amet" + (text) => text.insert(26, "."), // "Lorem ipsum dolor sit amet." +]; + +export default function Home() { + const roomId = getRoomFromUrl(); + return ( + + + + ); +} + +function Sandbox() { + const renderCount = useRenderCount(); + const room = useRoom(); + const status = useStatus(); + const undo = useUndo(); + const redo = useRedo(); + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + const text = useStorage((root) => root.text); + const me = useSelf(); + const syncStatus = useSyncStatus(); + + const insert = useMutation(({ storage }, value: string) => { + storage.get("text").insert(storage.get("text").length, value); + }, []); + + const formatHello = useMutation(({ storage }) => { + storage.get("text").format(0, 5, { bold: true }); + }, []); + + const unformatHello = useMutation(({ storage }) => { + storage.get("text").format(0, 5, { bold: null }); + }, []); + + const deleteFirst = useMutation(({ storage }) => { + storage.get("text").delete(0, 1); + }, []); + + const reset = useMutation(({ storage }) => { + const text = storage.get("text"); + text.replace(0, text.length, "Hello"); + }, []); + + // Set the doc to "Hello, world!" with "world!" bold, the starting point of + // the offline-rebase scenario. + const setupScenario = useMutation(({ storage }) => { + const text = storage.get("text"); + text.replace(0, text.length, "Hello, world!"); + text.format(7, 6, { bold: true }); + }, []); + + // Client A's offline edit: delete ", world" → "Hello!". + const deleteCommaWorld = useMutation(({ storage }) => { + storage.get("text").delete(5, 7); + }, []); + + const applyMorphStep = useMutation( + ({ storage }, step: (text: LiveText) => void) => { + step(storage.get("text")); + }, + [] + ); + + // Walk "Hello, world!" → "Lorem ipsum dolor sit amet." one op at a time, with + // a pause between each so the version climbs visibly. + const morphToLorem = async () => { + for (const step of MORPH_STEPS) { + applyMorphStep(step); + await sleep(600); + } + }; + + if (text === null || me === null) { + return
    Loading...
    ; + } + + const plainText = text.map(([segmentText]) => segmentText).join(""); + + return ( +
    +

    + Home › Storage › LiveText +

    +
    + + + + + + + +
    + +
    + + + +
    + +
    + + + +
    + + + + + + + + + +
    +
    + ); +} diff --git a/e2e/next-sandbox/test/storage/text.test.ts b/e2e/next-sandbox/test/storage/text.test.ts new file mode 100644 index 00000000000..ab1cf6c165e --- /dev/null +++ b/e2e/next-sandbox/test/storage/text.test.ts @@ -0,0 +1,48 @@ +import type { Page } from "@playwright/test"; +import { test } from "@playwright/test"; + +import { + genRoomId, + preparePages, + waitForJson, + waitUntilEqualOnAllPages, +} from "../utils"; + +test.describe.configure({ mode: "parallel" }); + +const TEST_URL = "http://localhost:3007/storage/text"; + +test.describe("Storage - LiveText", () => { + let pages: [Page, Page]; + + test.beforeEach(async ({}, testInfo) => { + const room = genRoomId(testInfo); + pages = await preparePages(`${TEST_URL}?room=${encodeURIComponent(room)}`); + }); + + test.afterEach(() => Promise.all(pages.map((page) => page.close()))); + + test("syncs text and range attributes", async () => { + const [page1, page2] = pages; + + await waitForJson(pages, "#plainText", "Hello"); + await waitForJson(pages, "#text", [["Hello"]]); + + await page1.click("#insert"); + await waitForJson(pages, "#plainText", "Hello world"); + await waitUntilEqualOnAllPages(pages, "#text"); + + await page2.click("#format"); + await waitForJson(pages, "#text", [ + ["Hello", { bold: true }], + [" world"], + ]); + + await page1.click("#unformat"); + await waitForJson(pages, "#text", [["Hello world"]]); + + await page2.click("#delete"); + await waitForJson(pages, "#plainText", "ello world"); + await waitForJson(pages, "#text", [["ello world"]]); + }); +}); diff --git a/e2e/next-tiptap-liveblocks/.gitignore b/e2e/next-tiptap-liveblocks/.gitignore new file mode 100644 index 00000000000..57b5f978e7a --- /dev/null +++ b/e2e/next-tiptap-liveblocks/.gitignore @@ -0,0 +1,4 @@ +.next/ +.turbo/ +next-env.d.ts +node_modules/ diff --git a/e2e/next-tiptap-liveblocks/app/api/auth/liveblocks/route.ts b/e2e/next-tiptap-liveblocks/app/api/auth/liveblocks/route.ts new file mode 100644 index 00000000000..2750a315005 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/api/auth/liveblocks/route.ts @@ -0,0 +1,51 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextResponse } from "next/server"; + +const USERS = [ + { + id: "tiptap-user-0", + info: { + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + }, + { + id: "tiptap-user-1", + info: { + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "tiptap-user-2", + info: { + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, +]; + +export async function POST() { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, + }); + + const user = USERS[Math.floor(Math.random() * USERS.length)]; + const session = liveblocks.prepareSession(user.id, { + userInfo: user.info, + }); + + session.allow("e2e-tiptap-*", session.FULL_ACCESS); + session.allow("liveblocks:e2e:tiptap:*", session.FULL_ACCESS); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/e2e/next-tiptap-liveblocks/app/globals.css b/e2e/next-tiptap-liveblocks/app/globals.css new file mode 100644 index 00000000000..0a853eda3c5 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/globals.css @@ -0,0 +1,81 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: #111827; + background: #f9fafb; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +button { + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 6px 10px; + background: white; + cursor: pointer; +} + +button[data-active="true"] { + color: white; + border-color: #111827; + background: #111827; +} + +main { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 24px; + max-width: 1280px; + min-height: 100vh; + margin: 0 auto; + padding: 32px; +} + +.editor-shell, +.diagnostics { + border: 1px solid #e5e7eb; + border-radius: 12px; + background: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.editor { + min-height: 460px; + padding: 24px; +} + +.editor .ProseMirror { + min-height: 420px; + outline: none; +} + +.diagnostics { + align-self: start; + padding: 16px; +} + +.diagnostics pre { + max-height: 280px; + overflow: auto; + border-radius: 8px; + padding: 12px; + background: #f3f4f6; + font-size: 12px; +} diff --git a/e2e/next-tiptap-liveblocks/app/layout.tsx b/e2e/next-tiptap-liveblocks/app/layout.tsx new file mode 100644 index 00000000000..92b231e52a2 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/layout.tsx @@ -0,0 +1,19 @@ +import "@liveblocks/react-tiptap/styles.css"; +import "@liveblocks/react-ui/styles.css"; +import "./globals.css"; + +import { Providers } from "./providers"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/e2e/next-tiptap-liveblocks/app/page.tsx b/e2e/next-tiptap-liveblocks/app/page.tsx new file mode 100644 index 00000000000..dad68faca45 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { nanoid } from "@liveblocks/client"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Page() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/rooms/e2e-tiptap-${nanoid()}`); + }, [router]); + + return
    Creating room...
    ; +} diff --git a/e2e/next-tiptap-liveblocks/app/providers.tsx b/e2e/next-tiptap-liveblocks/app/providers.tsx new file mode 100644 index 00000000000..25753e8afe3 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/providers.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; + +const USERS = [ + { + id: "tiptap-user-0", + name: "Ada Lovelace", + color: "#e11d48", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + { + id: "tiptap-user-1", + name: "Grace Hopper", + color: "#2563eb", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + { + id: "tiptap-user-2", + name: "Katherine Johnson", + color: "#16a34a", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, +]; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + { + const query = text.toLowerCase(); + + return USERS.filter((user) => + user.name.toLowerCase().includes(query) + ).map((user) => user.id); + }} + resolveUsers={async ({ userIds }) => { + return userIds.map((userId) => { + const user = USERS.find((user) => user.id === userId); + + return { + name: user?.name ?? userId, + color: user?.color ?? "#6b7280", + avatar: user?.avatar ?? "https://liveblocks.io/avatars/avatar-3.png", + }; + }); + }} + throttle={16} + > + {children} + + ); +} diff --git a/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/page.tsx b/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/page.tsx new file mode 100644 index 00000000000..22875285613 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ClientSideSuspense, RoomProvider } from "@liveblocks/react"; +import { use } from "react"; + +import { TiptapLiveblocksEditor } from "./tiptap-liveblocks-editor"; + +export default function RoomPage({ + params, +}: { + params: Promise<{ roomId: string }>; +}) { + const { roomId } = use(params); + + return ( + + Loading room...}> + + + + ); +} diff --git a/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/tiptap-liveblocks-editor.tsx b/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/tiptap-liveblocks-editor.tsx new file mode 100644 index 00000000000..0af75109c1c --- /dev/null +++ b/e2e/next-tiptap-liveblocks/app/rooms/[roomId]/tiptap-liveblocks-editor.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + FloatingComposer, + FloatingThreads, + FloatingToolbar, + useLiveblocksExtension, +} from "@liveblocks/react-tiptap"; +import { useThreads } from "@liveblocks/react/suspense"; +import { useStorage, useSyncStatus } from "@liveblocks/react/suspense"; +import { type Editor, EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useMemo, useState } from "react"; + +const INITIAL_CONTENT = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello from LiveText-backed Tiptap." }], + }, + ], +}; + +export function TiptapLiveblocksEditor({ roomId }: { roomId: string }) { + const syncStatus = useSyncStatus(); + const { threads } = useThreads(); + const liveblocks = useLiveblocksExtension({ + collaborationMode: "liveblocks", + field: "document", + initialContent: INITIAL_CONTENT, + }); + const [showDiagnostics, setShowDiagnostics] = useState(false); + + const editor = useEditor({ + immediatelyRender: false, + editorProps: { + attributes: { + class: "editor", + }, + }, + extensions: [ + StarterKit.configure({ + undoRedo: false, + }), + liveblocks, + ], + }); + + return ( +
    +
    + + + +
    + + +
    + ); +} + +function Diagnostics({ editor }: { editor: Editor | null }) { + const document = useStorage((root) => root._tiptap_docs?.document); + const [editorJson, setEditorJson] = useState(null); + + const editorJsonText = useMemo( + () => JSON.stringify(editorJson, null, 2), + [editorJson] + ); + const storageJsonText = useMemo( + () => JSON.stringify(document, null, 2), + [document] + ); + + return ( + <> + + +

    Editor JSON

    +
    {editorJsonText}
    + +

    Storage JSON

    +
    {storageJsonText}
    + + ); +} + +function Threads({ + editor, + threads, +}: { + editor: Editor | null; + threads: ReturnType["threads"]; +}) { + if (!editor) { + return null; + } + + return ( + <> + + + ); +} diff --git a/e2e/next-tiptap-liveblocks/eslint.config.mjs b/e2e/next-tiptap-liveblocks/eslint.config.mjs new file mode 100644 index 00000000000..348c45a2fd8 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/e2e/next-tiptap-liveblocks/liveblocks.config.ts b/e2e/next-tiptap-liveblocks/liveblocks.config.ts new file mode 100644 index 00000000000..41c50dc93e7 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/liveblocks.config.ts @@ -0,0 +1,34 @@ +import type { Json } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Presence: { + liveblocksTiptap?: { + field: string; + anchor: number; + head: number; + user?: { + name?: string; + color?: string; + }; + } | null; + }; + + Storage: { + _tiptap_docs?: { + document?: Json; + }; + }; + + UserMeta: { + id: string; + info: { + name: string; + color: string; + avatar: string; + }; + }; + } +} + +export {}; diff --git a/e2e/next-tiptap-liveblocks/next-env.d.ts b/e2e/next-tiptap-liveblocks/next-env.d.ts new file mode 100644 index 00000000000..9edff1c7cac --- /dev/null +++ b/e2e/next-tiptap-liveblocks/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/e2e/next-tiptap-liveblocks/next.config.ts b/e2e/next-tiptap-liveblocks/next.config.ts new file mode 100644 index 00000000000..cb651cdc007 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/e2e/next-tiptap-liveblocks/package.json b/e2e/next-tiptap-liveblocks/package.json new file mode 100644 index 00000000000..c78439f86f6 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/package.json @@ -0,0 +1,33 @@ +{ + "name": "@liveblocks/next-tiptap-liveblocks", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3009", + "build": "next build", + "start": "next start --port 3009", + "lint": "next lint" + }, + "dependencies": { + "@liveblocks/client": "workspace:*", + "@liveblocks/node": "workspace:*", + "@liveblocks/react": "workspace:*", + "@liveblocks/react-tiptap": "workspace:*", + "@liveblocks/react-ui": "workspace:*", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", + "next": "16.1.4", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.3", + "@types/node": "^24.12.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "eslint": "^9.39.4", + "eslint-config-next": "16.1.4", + "typescript": "^5.9.3" + } +} diff --git a/e2e/next-tiptap-liveblocks/tsconfig.json b/e2e/next-tiptap-liveblocks/tsconfig.json new file mode 100644 index 00000000000..705f5ce5e36 --- /dev/null +++ b/e2e/next-tiptap-liveblocks/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/e2e/next-tiptap-liveblocks/turbo.json b/e2e/next-tiptap-liveblocks/turbo.json new file mode 100644 index 00000000000..d67cb020b1e --- /dev/null +++ b/e2e/next-tiptap-liveblocks/turbo.json @@ -0,0 +1,17 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["build"], + "cache": false + }, + "test:ci": { + "dependsOn": ["build"], + "cache": false + }, + "test:ui": { + "dependsOn": ["build"], + "cache": false + } + } +} diff --git a/examples/nextjs-blocknote/app/api/liveblocks-auth/route.ts b/examples/nextjs-blocknote/app/api/liveblocks-auth/route.ts index 9b6f920ef0f..6d343f7719e 100644 --- a/examples/nextjs-blocknote/app/api/liveblocks-auth/route.ts +++ b/examples/nextjs-blocknote/app/api/liveblocks-auth/route.ts @@ -9,6 +9,7 @@ import { getRandomUser } from "../database"; const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, }); export async function POST(request: NextRequest) { diff --git a/examples/nextjs-blocknote/app/blocknote/editor.tsx b/examples/nextjs-blocknote/app/blocknote/editor.tsx index f2c4d6beffc..1ea1362e12b 100644 --- a/examples/nextjs-blocknote/app/blocknote/editor.tsx +++ b/examples/nextjs-blocknote/app/blocknote/editor.tsx @@ -14,7 +14,10 @@ import { useIsMobile } from "./use-is-mobile"; import VersionsDialog from "../version-history-dialog"; export default function TextEditor() { - const editor = useCreateBlockNoteWithLiveblocks({}, { mentions: true }); + const editor = useCreateBlockNoteWithLiveblocks({}, { + collaborationMode: "liveblocks", + mentions: true + }); return (
    diff --git a/examples/nextjs-blocknote/app/providers.tsx b/examples/nextjs-blocknote/app/providers.tsx index 9f24dbdd4d7..86c1aa0ad33 100644 --- a/examples/nextjs-blocknote/app/providers.tsx +++ b/examples/nextjs-blocknote/app/providers.tsx @@ -7,6 +7,7 @@ import { LiveblocksProvider } from "@liveblocks/react/suspense"; export function Providers({ children }: { children: ReactNode }) { return ( { const searchParams = new URLSearchParams( diff --git a/examples/nextjs-livetext-custom/.env.example b/examples/nextjs-livetext-custom/.env.example new file mode 100644 index 00000000000..48ac8d33600 --- /dev/null +++ b/examples/nextjs-livetext-custom/.env.example @@ -0,0 +1,2 @@ +# https://liveblocks.io/dashboard/apikeys +LIVEBLOCKS_SECRET_KEY= \ No newline at end of file diff --git a/examples/nextjs-livetext-custom/.gitignore b/examples/nextjs-livetext-custom/.gitignore new file mode 100644 index 00000000000..1dd1e7cb0cf --- /dev/null +++ b/examples/nextjs-livetext-custom/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +.env +.env.* +!.env.example +*.tsbuildinfo +.vercel +.next +out +next-env.d.ts diff --git a/examples/nextjs-livetext-custom/.prettierrc b/examples/nextjs-livetext-custom/.prettierrc new file mode 100644 index 00000000000..06998724304 --- /dev/null +++ b/examples/nextjs-livetext-custom/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "tabWidth": 2, + "useTabs": false, + "singleQuote": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "trailingComma": "es5" +} diff --git a/examples/nextjs-livetext-custom/README.md b/examples/nextjs-livetext-custom/README.md new file mode 100644 index 00000000000..f3959f79a7e --- /dev/null +++ b/examples/nextjs-livetext-custom/README.md @@ -0,0 +1,104 @@ +

    + + Liveblocks + + + Liveblocks + +

    + +# Collaborative Text Editor (Custom) + +

    + + Live Preview + + + Open in CodeSandbox + + React + Next.js +

    + +This example shows how to build a custom collaborative text editor with +[Liveblocks](https://liveblocks.io), a plain `contenteditable`, and +[Next.js](https://nextjs.org/), without using a rich-text editor framework. + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example nextjs-livetext-custom --api-key +``` + +This will download the example and ask permission to open your browser, enabling +you to automatically get your API key from your +[liveblocks.io](https://liveblocks.io) account. + +### Manual setup + +
    Read more + +

    + +Alternatively, you can set up your project manually: + +- Install all dependencies with `npm install` +- Create an account on [liveblocks.io](https://liveblocks.io/dashboard) +- Copy your **secret** key from the + [dashboard](https://liveblocks.io/dashboard/apikeys) +- Create an `.env.local` file and add your **secret** key as the + `LIVEBLOCKS_SECRET_KEY` environment variable +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +
    + +### Dev server setup + +
    Read more + +

    + +You can optionally run this example locally using the +[Liveblocks dev server](https://liveblocks.io/docs/tools/dev-server). + +- Install the example as detailed above +- Run `npx liveblocks dev` to start the server +- Add `baseUrl: "http://localhost:1153"` to `LiveblocksProvider` and + `new Liveblocks` +- Replace `secret` in `new Liveblocks` with `"sk_localdev"` +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +
    + +### Deploy on Vercel + +
    Read more + +

    + +To both deploy on [Vercel](https://vercel.com), and run the example locally, use +the following command: + +```bash +npx create-liveblocks-app@latest --example nextjs-livetext-custom --vercel +``` + +This will download the example and ask permission to open your browser, enabling +you to deploy to Vercel. + +
    + +### Develop on CodeSandbox + +
    Read more + +

    + +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-livetext-custom) +on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a +[secret](https://codesandbox.io/docs/secrets). + +
    diff --git a/examples/nextjs-livetext-custom/app/api/database.ts b/examples/nextjs-livetext-custom/app/api/database.ts new file mode 100644 index 00000000000..09888d62223 --- /dev/null +++ b/examples/nextjs-livetext-custom/app/api/database.ts @@ -0,0 +1,83 @@ +// A mock database with example users +const USER_INFO: Liveblocks["UserMeta"][] = [ + { + id: "charlie.layne@example.com", + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: "tatum.paolo@example.com", + info: { + name: "Tatum Paolo", + color: "#F0D885", + avatar: "https://liveblocks.io/avatars/avatar-3.png", + }, + }, + { + id: "anjali.wanda@example.com", + info: { + name: "Anjali Wanda", + color: "#85EED6", + avatar: "https://liveblocks.io/avatars/avatar-4.png", + }, + }, + { + id: "jody.hekla@example.com", + info: { + name: "Jody Hekla", + color: "#85BBF0", + avatar: "https://liveblocks.io/avatars/avatar-5.png", + }, + }, + { + id: "emil.joyce@example.com", + info: { + name: "Emil Joyce", + color: "#8594F0", + avatar: "https://liveblocks.io/avatars/avatar-6.png", + }, + }, + { + id: "jory.quispe@example.com", + info: { + name: "Jory Quispe", + color: "#85DBF0", + avatar: "https://liveblocks.io/avatars/avatar-7.png", + }, + }, + { + id: "quinn.elton@example.com", + info: { + name: "Quinn Elton", + color: "#87EE85", + avatar: "https://liveblocks.io/avatars/avatar-8.png", + }, + }, +]; + +export function getRandomUser() { + return USER_INFO[Math.floor(Math.random() * 10) % USER_INFO.length]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; +} + +export async function getUsers(ids: string[]) { + return ids.map((id) => getUser(id)); +} + +export function getAllUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-livetext-custom/app/api/liveblocks-auth/route.ts b/examples/nextjs-livetext-custom/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..6d343f7719e --- /dev/null +++ b/examples/nextjs-livetext-custom/app/api/liveblocks-auth/route.ts @@ -0,0 +1,35 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { getRandomUser } from "../database"; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL, +}); + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + // Get the current user's unique id and info from your database + const user = getRandomUser(); + + // Create a session for the current user (access token auth) + const session = liveblocks.prepareSession(`${user.id}`, { + userInfo: user.info, + }); + + // Use a naming pattern to allow access to rooms with a wildcard + session.allow(`liveblocks:examples:*`, ["*:write"]); + + // Authorize the user and return the result + const { status, body } = await session.authorize(); + + return new NextResponse(body, { status }); +} diff --git a/examples/nextjs-livetext-custom/app/api/users/route.ts b/examples/nextjs-livetext-custom/app/api/users/route.ts new file mode 100644 index 00000000000..c44713f6657 --- /dev/null +++ b/examples/nextjs-livetext-custom/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "../database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const userIds = searchParams.getAll("userIds"); + + if (!userIds || !Array.isArray(userIds)) { + return new NextResponse("Missing or invalid userIds", { status: 400 }); + } + + return NextResponse.json( + userIds.map((userId) => getUser(userId)?.info || null), + { status: 200 } + ); +} diff --git a/examples/nextjs-livetext-custom/app/globals.css b/examples/nextjs-livetext-custom/app/globals.css new file mode 100644 index 00000000000..b9b571beb2d --- /dev/null +++ b/examples/nextjs-livetext-custom/app/globals.css @@ -0,0 +1,179 @@ +@import "@liveblocks/react-ui/styles.css"; + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --background: #f3f3f3; + --surface: #ffffff; + --border: rgba(0, 0, 0, 0.08); + --text: #111827; + --text-subtle: #6b7280; + --accent: #8145ff; + --accent-soft: rgba(129, 69, 255, 0.12); +} + +html, +body, +main { + height: 100%; +} + +body { + background: var(--background); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +main { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.loading img { + width: 64px; + height: 64px; + opacity: 0.2; +} + +.editor-container { + width: 100%; + max-width: 640px; + background: var(--surface); + border-radius: 12px; + box-shadow: + 0 0 0 1px var(--border), + 0 2px 6px rgba(0, 0, 0, 0.04), + 0 8px 26px rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.toolbar { + display: flex; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--border); +} + +.toolbar-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-subtle); + font-size: 14px; + font-family: inherit; + cursor: pointer; + transition: + background-color 0.1s ease, + color 0.1s ease; +} + +.toolbar-button:hover { + background: rgba(0, 0, 0, 0.05); + color: var(--text); +} + +.toolbar-button.active { + background: var(--accent-soft); + color: var(--accent); +} + +.toolbar-button:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +.toolbar-divider { + width: 1px; + align-self: stretch; + margin: 4px 4px; + background: var(--border); +} + +.toolbar .avatars { + margin-left: auto; + align-self: center; +} + +.editor-wrapper { + position: relative; +} + +.remote-selections { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.remote-highlight { + position: absolute; + opacity: 0.2; + border-radius: 2px; +} + +.remote-caret { + position: absolute; + border-radius: 1px; +} + +.remote-caret-name { + position: absolute; + top: -17px; + left: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 4px 1px 4px; + border-radius: 4px; + border-bottom-left-radius: 0; + color: #ffffff; + font-size: 11px; + font-weight: 500; + line-height: 16px; + white-space: nowrap; +} + +.remote-caret-avatar { + width: 18px; + height: 18px; + border-radius: 9999; + position: absolute; + right: 100%; + margin-right: 4px; +} + +.editor { + min-height: 280px; + padding: 24px 28px; + font-size: 16px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: break-word; + caret-color: var(--accent); + outline: none; +} + +.editor::selection, +.editor *::selection { + background: var(--accent-soft); +} diff --git a/examples/nextjs-livetext-custom/app/layout.tsx b/examples/nextjs-livetext-custom/app/layout.tsx new file mode 100644 index 00000000000..14175b52885 --- /dev/null +++ b/examples/nextjs-livetext-custom/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { HelpButton } from "../components/help-button"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Liveblocks", +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + +
    {children}
    + + + + ); +} diff --git a/examples/nextjs-livetext-custom/app/loading.tsx b/examples/nextjs-livetext-custom/app/loading.tsx new file mode 100644 index 00000000000..4c0d47b86f0 --- /dev/null +++ b/examples/nextjs-livetext-custom/app/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
    + Loading +
    + ); +} diff --git a/examples/nextjs-livetext-custom/app/page.tsx b/examples/nextjs-livetext-custom/app/page.tsx new file mode 100644 index 00000000000..78cd4d12c71 --- /dev/null +++ b/examples/nextjs-livetext-custom/app/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { LiveText } from "@liveblocks/client"; +import { + ClientSideSuspense, + LiveblocksProvider, + RoomProvider, +} from "@liveblocks/react/suspense"; +import { useSearchParams } from "next/navigation"; +import { Editor } from "../components/editor"; +import Loading from "./loading"; + +// Learn how to structure your collaborative Next.js app +// https://liveblocks.io/docs/guides/how-to-use-liveblocks-with-nextjs-app-directory + +export default function Page() { + const roomId = useExampleRoomId( + "liveblocks:examples:nextjs-livetext-custom" + ); + + return ( + { + const searchParams = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${searchParams}`); + + if (!response.ok) { + throw new Error("Problem resolving users"); + } + + return await response.json(); + }} + > + + }> + + + + + ); +} + +/** + * This function is used when deploying an example on liveblocks.io. + * You can ignore it completely if you run the example locally. + */ +function useExampleRoomId(roomId: string) { + const params = useSearchParams(); + const exampleId = params?.get("exampleId"); + return exampleId ? `${roomId}-${exampleId}` : roomId; +} diff --git a/examples/nextjs-livetext-custom/components/dom-selection.ts b/examples/nextjs-livetext-custom/components/dom-selection.ts new file mode 100644 index 00000000000..bcd22aaf973 --- /dev/null +++ b/examples/nextjs-livetext-custom/components/dom-selection.ts @@ -0,0 +1,88 @@ +import type { SelectionRange } from "./live-text-formatting"; + +// Converts a DOM position (node + offset) into a character offset into the editor +export function getAbsoluteOffset( + element: HTMLElement, + node: Node, + offsetInNode: number +): number { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, offsetInNode); + return range.toString().length; +} + +// Reads the DOM selection as character offsets, preserving direction (anchor → focus) +export function getSelectionRange( + element: HTMLElement +): SelectionRange | null { + const domSelection = window.getSelection(); + if ( + !domSelection || + domSelection.anchorNode === null || + domSelection.focusNode === null || + !element.contains(domSelection.anchorNode) || + !element.contains(domSelection.focusNode) + ) { + return null; + } + + return { + anchor: getAbsoluteOffset( + element, + domSelection.anchorNode, + domSelection.anchorOffset + ), + focus: getAbsoluteOffset( + element, + domSelection.focusNode, + domSelection.focusOffset + ), + }; +} + +// Converts a character offset back into a DOM position inside the editor +export function resolveDomPoint( + element: HTMLElement, + offset: number +): { node: Node; offset: number } { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = offset; + let lastTextNode: Text | null = null; + + let node = walker.nextNode(); + while (node) { + const textNode = node as Text; + if (remaining <= textNode.length) { + return { node: textNode, offset: remaining }; + } + remaining -= textNode.length; + lastTextNode = textNode; + node = walker.nextNode(); + } + + if (lastTextNode) { + return { node: lastTextNode, offset: lastTextNode.length }; + } + return { node: element, offset: 0 }; +} + +// Applies character offsets as the DOM selection, preserving direction (anchor → focus) +export function setSelectionRange( + element: HTMLElement, + range: SelectionRange +): void { + const domSelection = window.getSelection(); + if (!domSelection) { + return; + } + + const anchor = resolveDomPoint(element, range.anchor); + const focus = resolveDomPoint(element, range.focus); + domSelection.setBaseAndExtent( + anchor.node, + anchor.offset, + focus.node, + focus.offset + ); +} diff --git a/examples/nextjs-livetext-custom/components/editor.tsx b/examples/nextjs-livetext-custom/components/editor.tsx new file mode 100644 index 00000000000..c89c57076e1 --- /dev/null +++ b/examples/nextjs-livetext-custom/components/editor.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { LiveTextAttributes } from "@liveblocks/client"; +import type { CSSProperties } from "react"; +import { LiveCarets } from "./live-carets"; +import { Toolbar } from "./toolbar"; +import { useLiveTextEditor } from "./use-live-text-editor"; + +// A collaborative text editor built on the LiveText primitive and a plain contenteditable +// We generally recommend using a rich-text editor framework such aas Tiptap, BlockNote, Lexical, etc. +// This is an example of a way you could construct your own using LiveText and presence. +export function Editor() { + const { + editorRef, + text, + selection, + historyBatchActive, + endHistoryBatch, + toggleFormat, + } = useLiveTextEditor(); + + const plainText = text.map(([segmentText]) => segmentText).join(""); + + return ( +
    + +
    +
    + {text.length === 0 ? ( +
    + ) : ( + text.map(([segmentText, attributes], index) => ( + + {segmentText} + + )) + )} + {/* A trailing newline needs an extra
    to render as a line */} + {plainText.endsWith("\n") ?
    : null} +
    + +
    +
    + ); +} + +function getSegmentStyle( + attributes: LiveTextAttributes | undefined +): CSSProperties | undefined { + if (!attributes) { + return undefined; + } + + return { + fontWeight: attributes.bold ? 700 : undefined, + fontStyle: attributes.italic ? "italic" : undefined, + textDecoration: attributes.strikethrough ? "line-through" : undefined, + }; +} diff --git a/examples/nextjs-livetext-custom/components/help-button.tsx b/examples/nextjs-livetext-custom/components/help-button.tsx new file mode 100644 index 00000000000..77fc091e35c --- /dev/null +++ b/examples/nextjs-livetext-custom/components/help-button.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { CSSProperties, ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +const EXAMPLE_NAME = "Collaborative Text Editor (LiveText primitive)"; + +const EXAMPLE_URL = + "https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-livetext-custom"; + +type Feature = { + icon: ReactNode; + title: string; + description: string; +}; + +const FEATURES: Feature[] = [ + { + icon: , + title: "LiveText", + description: + "Type anywhere in the editor—every keystroke becomes a LiveText operation and syncs to everyone in real-time.", + }, + { + icon: , + title: "Inline formatting", + description: + "Select some text and use the toolbar, or ⌘B, ⌘I, and ⌘⇧X, to toggle bold, italic, and strikethrough.", + }, + { + icon: , + title: "Conflict resolution", + description: + "Simultaneous edits are merged with operational transformation, so every client converges on the same text.", + }, + { + icon: , + title: "Undo & redo", + description: + "⌘Z and ⌘⇧Z step through your own edit history, even while others keep editing around you.", + }, + { + icon: , + title: "No editor framework", + description: + "Built on a plain contenteditable—the DOM is always rendered straight from Liveblocks Storage.", + }, +]; + +const styles: Record = { + button: { + position: "fixed", + bottom: 16, + left: 16, + zIndex: 2147483000, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 36, + height: 36, + background: "#ffffff", + border: "1px solid #e5e5e5", + borderRadius: 9999, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + color: "#737373", + cursor: "pointer", + }, + backdrop: { + position: "fixed", + inset: 0, + zIndex: 2147483000, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 16, + background: "rgba(23, 23, 23, 0.2)", + }, + panel: { + background: "#ffffff", + border: "1px solid #e5e5e5", + borderRadius: 8, + boxShadow: + "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + width: "100%", + maxWidth: 448, + maxHeight: "80vh", + overflowY: "auto", + }, + header: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 16, + padding: 20, + borderBottom: "1px solid #e5e5e5", + }, + title: { + fontSize: 14, + fontWeight: 600, + color: "#171717", + margin: 0, + }, + titleLink: { + color: "inherit", + textDecoration: "none", + }, + desc: { + fontSize: 14, + color: "#737373", + marginTop: 4, + marginBottom: 0, + }, + close: { + flexShrink: 0, + marginTop: -4, + marginRight: -4, + padding: 6, + borderRadius: 4, + border: "none", + background: "transparent", + color: "#737373", + cursor: "pointer", + lineHeight: 0, + }, + list: { + listStyle: "none", + margin: 0, + padding: 20, + display: "flex", + flexDirection: "column", + gap: 16, + }, + item: { + display: "flex", + alignItems: "flex-start", + gap: 16, + }, + iconWrap: { + flexShrink: 0, + marginTop: 2, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 4, + background: "#f5f5f5", + color: "#404040", + }, + featureTitle: { + fontSize: 14, + fontWeight: 500, + color: "#171717", + margin: 0, + }, + featureDesc: { + fontSize: 14, + color: "#737373", + marginTop: 2, + marginBottom: 0, + }, +}; + +const HOVER_CSS = ` +.lb-help-button:hover { background:#fafafa !important; color:#171717 !important; } +.lb-help-title-link:hover { text-decoration: underline !important; } +.lb-help-close:hover { background:#f5f5f5 !important; color:#171717 !important; } +.lb-help, .lb-help * { box-sizing: border-box; } +.lb-help h2 { font-size: 14px !important; font-weight: 600 !important; line-height: 1.4 !important; margin: 0 !important; } +.lb-help h2 a { font-size: inherit !important; font-weight: inherit !important; } +.lb-help h3 { font-size: 14px !important; font-weight: 500 !important; line-height: 1.4 !important; margin: 0 !important; } +.lb-help p { font-size: 14px !important; line-height: 1.45 !important; } +.lb-help ul { list-style: none !important; } +`; + +export function HelpButton() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isOpen]); + + return ( + <> + + + + {isOpen && typeof document !== "undefined" + ? createPortal( +
    setIsOpen(false)} + > +
    event.stopPropagation()} + > +
    +
    +

    + + {EXAMPLE_NAME} + +

    +

    How to use this example

    +
    + +
    + +
      + {FEATURES.map((feature) => ( +
    • + {feature.icon} +
      +

      {feature.title}

      +

      {feature.description}

      +
      +
    • + ))} +
    +
    +
    , + document.body + ) + : null} + + ); +} + +function HelpIcon() { + return ( + + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function FeatureIconBase({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function TypeIcon() { + return ( + + + + + + ); +} + +function BoldIcon() { + return ( + + + + ); +} + +function MergeIcon() { + return ( + + + + + + ); +} + +function UndoIcon() { + return ( + + + + + ); +} + +function CodeIcon() { + return ( + + + + + ); +} diff --git a/examples/nextjs-livetext-custom/components/live-carets.tsx b/examples/nextjs-livetext-custom/components/live-carets.tsx new file mode 100644 index 00000000000..35c9f26034a --- /dev/null +++ b/examples/nextjs-livetext-custom/components/live-carets.tsx @@ -0,0 +1,151 @@ +"use client"; + +import type { LiveTextData } from "@liveblocks/client"; +import { shallow, useOthersMapped } from "@liveblocks/react/suspense"; +import { Avatar } from "@liveblocks/react-ui"; +import type { CSSProperties, RefObject } from "react"; +import { useLayoutEffect, useState } from "react"; +import { resolveDomPoint } from "./dom-selection"; + +type Rect = { left: number; top: number; width: number; height: number }; + +type LiveCaret = { + connectionId: number; + userInfo: Liveblocks["UserMeta"]["info"]; + caret: Rect; + highlights: Rect[]; +}; + +// Renders every other user's caret and selection highlight, from presence +export function LiveCarets({ + editorRef, + text, +}: { + editorRef: RefObject; + text: LiveTextData; +}) { + const others = useOthersMapped( + (other) => ({ + selection: other.presence.selection, + userInfo: other.info, + }), + shallow + ); + const [liveCarets, setLiveCarets] = useState([]); + + useLayoutEffect(() => { + const element = editorRef.current; + const container = element?.parentElement; + if (!element || !container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const documentLength = text.reduce( + (length, [segmentText]) => length + segmentText.length, + 0 + ); + + function toLocalRect(rect: DOMRect): Rect { + return { + left: rect.left - containerRect.left, + top: rect.top - containerRect.top, + width: rect.width, + height: rect.height, + }; + } + + const next: LiveCaret[] = []; + for (const [connectionId, data] of others) { + const { selection, userInfo } = data; + if (!selection) { + continue; + } + + const clamp = (offset: number) => + Math.max(0, Math.min(offset, documentLength)); + const anchor = clamp(selection.anchor); + const focus = clamp(selection.focus); + const start = Math.min(anchor, focus); + const end = Math.max(anchor, focus); + + const startPoint = resolveDomPoint(element, start); + const endPoint = resolveDomPoint(element, end); + const range = document.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + const highlights = + start === end + ? [] + : Array.from(range.getClientRects()).map(toLocalRect); + + // The caret sits at the focus, which is before the anchor when selecting backwards + const focusPoint = resolveDomPoint(element, focus); + const caretRange = document.createRange(); + caretRange.setStart(focusPoint.node, focusPoint.offset); + caretRange.collapse(true); + let caretRect: DOMRect | undefined = caretRange.getClientRects()[0]; + if (!caretRect || caretRect.height === 0) { + // Collapsed ranges have no rect in some positions; fall back to the first line + const fallbackRange = document.createRange(); + fallbackRange.selectNodeContents(element); + caretRect = + fallbackRange.getClientRects()[0] ?? + fallbackRange.getBoundingClientRect(); + } + + next.push({ + connectionId, + userInfo, + caret: { + ...toLocalRect(caretRect), + width: 2, + }, + highlights, + }); + } + + setLiveCarets(next); + }, [others, text, editorRef]); + + return ( +
    + {liveCarets.map(({ connectionId, userInfo, caret, highlights }) => ( +
    + {highlights.map((rect, index) => ( +
    + ))} +
    + + + {userInfo.name} + +
    +
    + ))} +
    + ); +} + +function rectStyle(rect: Rect): CSSProperties { + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} diff --git a/examples/nextjs-livetext-custom/components/live-text-formatting.ts b/examples/nextjs-livetext-custom/components/live-text-formatting.ts new file mode 100644 index 00000000000..d00cdb50cf5 --- /dev/null +++ b/examples/nextjs-livetext-custom/components/live-text-formatting.ts @@ -0,0 +1,62 @@ +import type { LiveTextAttributes, LiveTextData } from "@liveblocks/client"; + +// A selection as character offsets. `focus` is the caret and may be before +// `anchor` when selecting backwards +export type SelectionRange = { anchor: number; focus: number }; + +export type FormatKey = "bold" | "italic" | "strikethrough"; + +// Returns the attributes of the character at `index` +export function attributesAt( + data: LiveTextData, + index: number +): LiveTextAttributes | undefined { + let offset = 0; + for (const [segmentText, attributes] of data) { + if (index < offset + segmentText.length) { + return attributes; + } + offset += segmentText.length; + } + return undefined; +} + +// Whether every character in the range has the attribute +export function isFormatActive( + data: LiveTextData, + range: SelectionRange, + key: FormatKey +): boolean { + const start = Math.min(range.anchor, range.focus); + const end = Math.max(range.anchor, range.focus); + + if (start === end) { + const attributes = attributesAt(data, start > 0 ? start - 1 : 0); + return Boolean(attributes?.[key]); + } + + let offset = 0; + let overlaps = false; + for (const [segmentText, attributes] of data) { + const segmentStart = offset; + const segmentEnd = offset + segmentText.length; + offset = segmentEnd; + + if (segmentEnd <= start || segmentStart >= end) { + continue; + } + if (!attributes?.[key]) { + return false; + } + overlaps = true; + } + return overlaps; +} + +export function isSelectionFormatted( + data: LiveTextData, + range: SelectionRange | null, + key: FormatKey +): boolean { + return range !== null && isFormatActive(data, range, key); +} diff --git a/examples/nextjs-livetext-custom/components/toolbar.tsx b/examples/nextjs-livetext-custom/components/toolbar.tsx new file mode 100644 index 00000000000..678e8ff80cf --- /dev/null +++ b/examples/nextjs-livetext-custom/components/toolbar.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { LiveTextData } from "@liveblocks/client"; +import { + useCanRedo, + useCanUndo, + useRedo, + useUndo, +} from "@liveblocks/react/suspense"; +import { AvatarStack } from "@liveblocks/react-ui"; +import type { FormatKey, SelectionRange } from "./live-text-formatting"; +import { isSelectionFormatted } from "./live-text-formatting"; + +// Undo/redo, inline formatting toggles, and an avatar stack showing who is in the room +export function Toolbar({ + text, + selection, + historyBatchActive, + onHistoryAction, + onToggleFormat, +}: { + text: LiveTextData; + selection: SelectionRange | null; + historyBatchActive: boolean; + onHistoryAction: () => void; + onToggleFormat: (key: FormatKey) => void; +}) { + const undo = useUndo(); + const redo = useRedo(); + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + + return ( +
    + { + onHistoryAction(); + undo(); + }} + > + + + { + onHistoryAction(); + redo(); + }} + > + + +
    + onToggleFormat("bold")} + > + + + onToggleFormat("italic")} + > + + + onToggleFormat("strikethrough")} + > + + + +
    + ); +} + +function FormatButton({ + label, + active, + onToggle, + children, +}: { + label: string; + active: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function ToolbarButton({ + label, + disabled, + onPress, + children, +}: { + label: string; + disabled: boolean; + onPress: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function UndoIcon() { + return ( + + + + + ); +} + +function RedoIcon() { + return ( + + + + + ); +} + +function ItalicIcon() { + return ( + + + + + + ); +} + +function BoldIcon() { + return ( + + + + ); +} + +function StrikethroughIcon() { + return ( + + + + + + ); +} diff --git a/examples/nextjs-livetext-custom/components/use-live-text-editor.ts b/examples/nextjs-livetext-custom/components/use-live-text-editor.ts new file mode 100644 index 00000000000..d329c3ebb61 --- /dev/null +++ b/examples/nextjs-livetext-custom/components/use-live-text-editor.ts @@ -0,0 +1,411 @@ +import type { + LiveTextAttributesPatch, + LiveTextData, +} from "@liveblocks/client"; +import { + useMutation, + useRedo, + useRoom, + useStorage, + useStorageRoot, + useUndo, + useUpdateMyPresence, +} from "@liveblocks/react/suspense"; +import type { RefObject } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + getAbsoluteOffset, + getSelectionRange, + setSelectionRange, +} from "./dom-selection"; +import type { FormatKey, SelectionRange } from "./live-text-formatting"; +import { attributesAt, isFormatActive } from "./live-text-formatting"; + +// Binds a contenteditable to the LiveText in Storage: edits become LiveText operations, the DOM is always rendered from Storage, and the selection is tracked and shared through presence +export function useLiveTextEditor(): { + editorRef: RefObject; + text: LiveTextData; + selection: SelectionRange | null; + historyBatchActive: boolean; + endHistoryBatch: () => void; + toggleFormat: (key: FormatKey) => void; +} { + const text = useStorage((root) => root.text); + const room = useRoom(); + const [root] = useStorageRoot(); + const undo = useUndo(); + const redo = useRedo(); + const updateMyPresence = useUpdateMyPresence(); + + const editorRef = useRef(null); + + // The ref is the source of truth; the state mirrors it for toolbar re-renders + const selectionRef = useRef(null); + const [selection, setSelection] = useState(null); + const historyBatchPausedRef = useRef(false); + const historyIdleTimerRef = useRef | null>( + null + ); + const historyMaxTimerRef = useRef | null>(null); + const [historyBatchActive, setHistoryBatchActive] = useState(false); + + // Latest document snapshot, readable from native event handlers + const textRef = useRef(text); + textRef.current = text; + + const endHistoryBatch = useCallback(() => { + if (historyIdleTimerRef.current !== null) { + clearTimeout(historyIdleTimerRef.current); + historyIdleTimerRef.current = null; + } + if (historyMaxTimerRef.current !== null) { + clearTimeout(historyMaxTimerRef.current); + historyMaxTimerRef.current = null; + } + if (historyBatchPausedRef.current) { + room.history.resume(); + historyBatchPausedRef.current = false; + setHistoryBatchActive(false); + } + }, [room]); + + const continueHistoryBatch = useCallback(() => { + if (!historyBatchPausedRef.current) { + room.history.pause(); + historyBatchPausedRef.current = true; + setHistoryBatchActive(true); + historyMaxTimerRef.current = setTimeout(endHistoryBatch, 2000); + } + + if (historyIdleTimerRef.current !== null) { + clearTimeout(historyIdleTimerRef.current); + } + historyIdleTimerRef.current = setTimeout(endHistoryBatch, 1000); + }, [endHistoryBatch, room]); + + useEffect(() => endHistoryBatch, [endHistoryBatch]); + + const replaceText = useMutation( + ({ storage }, index: number, length: number, newText: string) => { + const liveText = storage.get("text"); + + // Inherit the preceding character's formatting, so typing inside bold text stays bold + const attributes = + newText.length > 0 + ? attributesAt(liveText.toJSON(), index > 0 ? index - 1 : 0) + : undefined; + + liveText.replace(index, length, newText, attributes); + }, + [] + ); + + const formatText = useMutation( + ( + { storage }, + index: number, + length: number, + attributes: LiveTextAttributesPatch + ) => { + storage.get("text").format(index, length, attributes); + }, + [] + ); + + function updateSelection(range: SelectionRange | null) { + const previous = selectionRef.current; + if ( + previous !== null && + range !== null && + (previous.anchor !== range.anchor || previous.focus !== range.focus) + ) { + endHistoryBatch(); + } + selectionRef.current = range; + setSelection(range); + updateMyPresence({ selection: range }); + } + + function toggleFormat(key: FormatKey) { + endHistoryBatch(); + const data = textRef.current; + const range = selectionRef.current; + if (!range || range.anchor === range.focus) { + return; + } + + const start = Math.min(range.anchor, range.focus); + const end = Math.max(range.anchor, range.focus); + const active = isFormatActive(data, range, key); + formatText(start, end - start, { + [key]: active ? null : true, + }); + } + + // Keep the selection offsets in sync with the DOM selection + useEffect(() => { + function handleSelectionChange() { + const element = editorRef.current; + if (!element) { + return; + } + + const range = getSelectionRange(element); + if (range) { + updateSelection(range); + } else if (document.activeElement !== element) { + // Selection left the editor: hide our caret for others, keep local offsets for refocus + endHistoryBatch(); + updateMyPresence({ selection: null }); + } + } + + document.addEventListener("selectionchange", handleSelectionChange); + return () => + document.removeEventListener("selectionchange", handleSelectionChange); + }, []); + + // Map the local selection through remote changes, so the caret stays in place + useEffect(() => { + if (root === null) { + return; + } + + const liveText = root.get("text"); + return room.subscribe( + liveText, + (updates) => { + for (const update of updates) { + if (update.type !== "LiveText") { + continue; + } + + const current = selectionRef.current; + if (!current) { + continue; + } + + let { anchor, focus } = current; + for (const change of update.updates) { + if (change.type === "insert") { + const length = change.text.length; + if (change.index < anchor) anchor += length; + if (change.index < focus) focus += length; + } else if (change.type === "delete") { + if (change.index < anchor) { + anchor = Math.max(change.index, anchor - change.length); + } + if (change.index < focus) { + focus = Math.max(change.index, focus - change.length); + } + } + } + selectionRef.current = { anchor, focus }; + updateMyPresence({ selection: { anchor, focus } }); + } + }, + { isDeep: true } + ); + }, [room, root, updateMyPresence]); + + // Intercept every edit in the contenteditable and turn it into a LiveText operation + useEffect(() => { + const element = editorRef.current; + if (!element) { + return; + } + + function setCaret(index: number) { + selectionRef.current = { anchor: index, focus: index }; + setSelection({ anchor: index, focus: index }); + updateMyPresence({ selection: { anchor: index, focus: index } }); + } + + function handleBeforeInput(event: InputEvent) { + event.preventDefault(); + if (!element) { + return; + } + + // Prefer the range the browser was about to modify (e.g. for word-deletion) + const targetRange = event.getTargetRanges?.()[0]; + const range = targetRange + ? { + anchor: getAbsoluteOffset( + element, + targetRange.startContainer, + targetRange.startOffset + ), + focus: getAbsoluteOffset( + element, + targetRange.endContainer, + targetRange.endOffset + ), + } + : (getSelectionRange(element) ?? selectionRef.current); + + if (!range) { + return; + } + + const start = Math.min(range.anchor, range.focus); + const end = Math.max(range.anchor, range.focus); + const length = end - start; + + switch (event.inputType) { + case "insertText": + case "insertReplacementText": { + const inserted = + event.data ?? event.dataTransfer?.getData("text/plain") ?? ""; + if (inserted.length === 0 && length === 0) { + return; + } + continueHistoryBatch(); + replaceText(start, length, inserted); + setCaret(start + inserted.length); + break; + } + + case "insertFromPaste": + case "insertFromDrop": { + const inserted = + event.data ?? event.dataTransfer?.getData("text/plain") ?? ""; + if (inserted.length === 0 && length === 0) { + return; + } + endHistoryBatch(); + replaceText(start, length, inserted); + setCaret(start + inserted.length); + break; + } + + case "insertParagraph": + case "insertLineBreak": { + endHistoryBatch(); + replaceText(start, length, "\n"); + setCaret(start + 1); + break; + } + + case "deleteContentBackward": + case "deleteContentForward": + case "deleteWordBackward": + case "deleteWordForward": + case "deleteSoftLineBackward": + case "deleteHardLineBackward": + case "deleteContent": { + if (length === 0) { + return; + } + continueHistoryBatch(); + replaceText(start, length, ""); + setCaret(start); + break; + } + + case "deleteByCut": { + if (length === 0) { + return; + } + endHistoryBatch(); + replaceText(start, length, ""); + setCaret(start); + break; + } + + case "formatBold": + toggleFormat("bold"); + break; + case "formatItalic": + toggleFormat("italic"); + break; + case "formatStrikeThrough": + toggleFormat("strikethrough"); + break; + + case "historyUndo": + endHistoryBatch(); + undo(); + break; + case "historyRedo": + endHistoryBatch(); + redo(); + break; + + default: + // Other input types (e.g. IME composition) are not supported + break; + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (!(event.metaKey || event.ctrlKey)) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "b") { + event.preventDefault(); + toggleFormat("bold"); + } else if (key === "i") { + event.preventDefault(); + toggleFormat("italic"); + } else if (key === "x" && event.shiftKey) { + event.preventDefault(); + toggleFormat("strikethrough"); + } else if (key === "z") { + event.preventDefault(); + endHistoryBatch(); + if (event.shiftKey) { + redo(); + } else { + undo(); + } + } + } + + element.addEventListener("beforeinput", handleBeforeInput); + element.addEventListener("keydown", handleKeyDown); + return () => { + element.removeEventListener("beforeinput", handleBeforeInput); + element.removeEventListener("keydown", handleKeyDown); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + replaceText, + formatText, + undo, + redo, + updateMyPresence, + continueHistoryBatch, + endHistoryBatch, + ]); + + // Re-rendering resets the DOM selection, so restore it after every document change + useLayoutEffect(() => { + const element = editorRef.current; + if (!element || document.activeElement !== element) { + return; + } + + const range = selectionRef.current; + if (range) { + setSelectionRange(element, range); + } + }, [text]); + + return { + editorRef, + text, + selection, + historyBatchActive, + endHistoryBatch, + toggleFormat, + }; +} diff --git a/examples/nextjs-livetext-custom/liveblocks.config.ts b/examples/nextjs-livetext-custom/liveblocks.config.ts new file mode 100644 index 00000000000..e7dedcdaf9a --- /dev/null +++ b/examples/nextjs-livetext-custom/liveblocks.config.ts @@ -0,0 +1,22 @@ +import type { LiveText } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Presence: { + selection: { anchor: number; focus: number } | null; + }; + Storage: { + text: LiveText; + }; + UserMeta: { + id: string; + info: { + name: string; + avatar: string; + color: string; + }; + }; + } +} + +export {}; diff --git a/examples/nextjs-livetext-custom/next.config.js b/examples/nextjs-livetext-custom/next.config.js new file mode 100644 index 00000000000..037ab1a52bc --- /dev/null +++ b/examples/nextjs-livetext-custom/next.config.js @@ -0,0 +1,7 @@ +/** @type {import("next").NextConfig} */ +const nextConfig = { + turbopack: { root: __dirname }, + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/examples/nextjs-livetext-custom/package-lock.json b/examples/nextjs-livetext-custom/package-lock.json new file mode 100644 index 00000000000..4601f69ef70 --- /dev/null +++ b/examples/nextjs-livetext-custom/package-lock.json @@ -0,0 +1,3117 @@ +{ + "name": "@liveblocks-examples/nextjs-livetext-custom", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@liveblocks-examples/nextjs-livetext-custom", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "3.23.0-exp2", + "@liveblocks/node": "3.23.0-exp2", + "@liveblocks/react": "3.23.0-exp2", + "@liveblocks/react-ui": "3.23.0-exp2", + "@types/node": "^18.11.19", + "@types/react": "^18.0.38", + "@types/react-dom": "^18.0.11", + "next": "^16.1.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.2.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@liveblocks/client": { + "version": "3.23.0-exp2", + "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.23.0-exp2.tgz", + "integrity": "sha512-iBNdhXFrg05uqGu6O1E53pgB1g6GWOOUOisACwMWDieRFPIK+lNRyal2wkC6b5OwKkZD5iCcseArDb2aVBAJVA==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "3.23.0-exp2" + } + }, + "node_modules/@liveblocks/core": { + "version": "3.23.0-exp2", + "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.23.0-exp2.tgz", + "integrity": "sha512-DGQR9EKKmBf8OZK4PPKNPYJkR+OAvMCrU8zqxOcej1N8pZ8nt0iFi9uty7AQC7f3h6l5qFDq9ediTk+KdUlNtg==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/json-schema": "^7" + } + }, + "node_modules/@liveblocks/node": { + "version": "3.23.0-exp2", + "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.23.0-exp2.tgz", + "integrity": "sha512-An43SblnfHBeb5RiwRJIiAfubZ18TNF6DzGkz/B0f5PXh6C9a6+BpF120jt3lNARePmDvJ63n8D6Le4RxadCFA==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "3.23.0-exp2", + "@stablelib/base64": "^1.0.1", + "fast-sha256": "^1.3.0", + "marked": "^15.0.11", + "node-fetch": "^2.6.1" + } + }, + "node_modules/@liveblocks/react": { + "version": "3.23.0-exp2", + "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.23.0-exp2.tgz", + "integrity": "sha512-TEVzOMTO2diDr/me3EmoMhg8QEXvrHJ2pRntb6g4j4Qlzor1/ELIJfWYwepQMge+TskbW1gvsf9sP0cpFlz1qw==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "3.23.0-exp2", + "@liveblocks/core": "3.23.0-exp2" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@liveblocks/react-ui": { + "version": "3.23.0-exp2", + "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.23.0-exp2.tgz", + "integrity": "sha512-61Ahsfbh3LfSZN7wAVAvcN3ytOMGDUAB2P+R2VlVB/YQUvyekSpmCUiQrkFxDWCo4ihdLc/0wSA/F3XzfT1Jpg==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/react-dom": "^2.1.0", + "@liveblocks/client": "3.23.0-exp2", + "@liveblocks/core": "3.23.0-exp2", + "@liveblocks/react": "3.23.0-exp2", + "frimousse": "^0.2.0", + "marked": "^15.0.11", + "radix-ui": "^1.4.0", + "slate": "^0.110.2", + "slate-history": "^0.110.3", + "slate-hyperscript": "^0.100.0", + "slate-react": "^0.110.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@next/env": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", + "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", + "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", + "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", + "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", + "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", + "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", + "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", + "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", + "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz", + "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.16.tgz", + "integrity": "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collapsible": "1.1.16", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.19.tgz", + "integrity": "sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", + "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz", + "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.2.tgz", + "integrity": "sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.7.tgz", + "integrity": "sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.16.tgz", + "integrity": "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", + "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.3.tgz", + "integrity": "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-menu": "2.1.20", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", + "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", + "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-effect-event": "0.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz", + "integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.20", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", + "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.12.tgz", + "integrity": "sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.19.tgz", + "integrity": "sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", + "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.20.tgz", + "integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.20.tgz", + "integrity": "sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.20", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.18.tgz", + "integrity": "sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.12.tgz", + "integrity": "sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.7.tgz", + "integrity": "sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.19.tgz", + "integrity": "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz", + "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.12.tgz", + "integrity": "sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.3.tgz", + "integrity": "sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz", + "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.14.tgz", + "integrity": "sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.3.tgz", + "integrity": "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", + "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.3.tgz", + "integrity": "sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.3.tgz", + "integrity": "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.17.tgz", + "integrity": "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.19.tgz", + "integrity": "sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.14.tgz", + "integrity": "sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.15.tgz", + "integrity": "sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-toggle": "1.1.14", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.15.tgz", + "integrity": "sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-toggle-group": "1.1.15" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.12.tgz", + "integrity": "sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", + "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", + "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/frimousse": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.2.0.tgz", + "integrity": "sha512-viSrsVQWKR4Q7xzC0lkx3Wu9i1+IHrth0QXn0nlIIJXpltwUnjkGXSTuoW7WHI5aJ4z49WR8E/pyQizFjlNtTA==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", + "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.10", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.10", + "@next/swc-darwin-x64": "16.2.10", + "@next/swc-linux-arm64-gnu": "16.2.10", + "@next/swc-linux-arm64-musl": "16.2.10", + "@next/swc-linux-x64-gnu": "16.2.10", + "@next/swc-linux-x64-musl": "16.2.10", + "@next/swc-win32-arm64-msvc": "16.2.10", + "@next/swc-win32-x64-msvc": "16.2.10", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/radix-ui": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.2.tgz", + "integrity": "sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-accessible-icon": "1.1.11", + "@radix-ui/react-accordion": "1.2.16", + "@radix-ui/react-alert-dialog": "1.1.19", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-aspect-ratio": "1.1.11", + "@radix-ui/react-avatar": "1.2.2", + "@radix-ui/react-checkbox": "1.3.7", + "@radix-ui/react-collapsible": "1.1.16", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-context-menu": "2.3.3", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.20", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-form": "0.1.12", + "@radix-ui/react-hover-card": "1.1.19", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-menu": "2.1.20", + "@radix-ui/react-menubar": "1.1.20", + "@radix-ui/react-navigation-menu": "1.2.18", + "@radix-ui/react-one-time-password-field": "0.1.12", + "@radix-ui/react-password-toggle-field": "0.1.7", + "@radix-ui/react-popover": "1.1.19", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-progress": "1.1.12", + "@radix-ui/react-radio-group": "1.4.3", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-scroll-area": "1.2.14", + "@radix-ui/react-select": "2.3.3", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-slider": "1.4.3", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.3", + "@radix-ui/react-tabs": "1.1.17", + "@radix-ui/react-toast": "1.2.19", + "@radix-ui/react-toggle": "1.1.14", + "@radix-ui/react-toggle-group": "1.1.15", + "@radix-ui/react-toolbar": "1.1.15", + "@radix-ui/react-tooltip": "1.2.12", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/slate": { + "version": "0.110.2", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.110.2.tgz", + "integrity": "sha512-4xGULnyMCiEQ0Ml7JAC1A6HVE6MNpPJU7Eq4cXh1LxlrR0dFXC3XC+rNfQtUJ7chHoPkws57x7DDiWiZAt+PBA==", + "license": "MIT", + "dependencies": { + "immer": "^10.0.3", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-history": { + "version": "0.110.3", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz", + "integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-hyperscript": { + "version": "0.100.0", + "resolved": "https://registry.npmjs.org/slate-hyperscript/-/slate-hyperscript-0.100.0.tgz", + "integrity": "sha512-fb2KdAYg6RkrQGlqaIi4wdqz3oa0S4zKNBJlbnJbNOwa23+9FLD6oPVx9zUGqCSIpy+HIpOeqXrg0Kzwh/Ii4A==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.110.3", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.110.3.tgz", + "integrity": "sha512-AS8PPjwmsFS3Lq0MOEegLVlFoxhyos68G6zz2nW4sh3WeTXV7pX0exnwtY1a/docn+J3LGQO11aZXTenPXA/kg==", + "license": "MIT", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.99.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/examples/nextjs-livetext-custom/package.json b/examples/nextjs-livetext-custom/package.json new file mode 100644 index 00000000000..496a8fc8d13 --- /dev/null +++ b/examples/nextjs-livetext-custom/package.json @@ -0,0 +1,25 @@ +{ + "name": "@liveblocks-examples/nextjs-livetext-custom", + "description": "This example shows how to build a collaborative text editor with the Liveblocks LiveText primitive, a contenteditable div, and Next.js.", + "license": "Apache-2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@liveblocks/client": "3.23.0-exp2", + "@liveblocks/node": "3.23.0-exp2", + "@liveblocks/react": "3.23.0-exp2", + "@liveblocks/react-ui": "3.23.0-exp2", + "@types/node": "^18.11.19", + "@types/react": "^18.0.38", + "@types/react-dom": "^18.0.11", + "next": "^16.1.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.2.2" + } +} diff --git a/examples/nextjs-livetext-custom/tsconfig.json b/examples/nextjs-livetext-custom/tsconfig.json new file mode 100644 index 00000000000..cf00ad7e976 --- /dev/null +++ b/examples/nextjs-livetext-custom/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/nextjs-livetext-custom/vercel.json b/examples/nextjs-livetext-custom/vercel.json new file mode 100644 index 00000000000..5d7edc91130 --- /dev/null +++ b/examples/nextjs-livetext-custom/vercel.json @@ -0,0 +1,4 @@ +{ + "installCommand": "npm install", + "buildCommand": "npm run build" +} diff --git a/guides/pages/modifying-storage-via-rest-api-with-json-patch.mdx b/guides/pages/modifying-storage-via-rest-api-with-json-patch.mdx index 1d77aed80b6..5e98fc69cb9 100644 --- a/guides/pages/modifying-storage-via-rest-api-with-json-patch.mdx +++ b/guides/pages/modifying-storage-via-rest-api-with-json-patch.mdx @@ -41,6 +41,7 @@ Storage is a tree of Liveblocks types: [LiveObject](/docs/api-reference/liveblocks-client#LiveObject), [LiveList](/docs/api-reference/liveblocks-client#LiveList), and [LiveMap](/docs/api-reference/liveblocks-client#LiveMap). It can also contain +[LiveText](/docs/api-reference/liveblocks-client#LiveText) and [LiveFile](/docs/api-reference/liveblocks-client#LiveFile) nodes. When building your patch: @@ -51,6 +52,12 @@ your patch: - **Nested values in `add` and `replace`:** If you pass a complex nested object or array as the `value` for an `add` or `replace` operation, it is automatically converted into LiveObjects and LiveLists on the server. +- **LiveText values:** LiveText is a leaf node: only the LiveText node itself is + addressable, not fields under its serialized `data`. To replace its contents, + use `replace` with a string or a `LiveTextData` array, for example + `{ "op": "replace", "path": "/text", "value": [["Hello"]] }`. To remove a + LiveText, remove the whole node (for example, `/text`). LiveText versioning is + an internal detail and is not part of this API. - **LiveFile values:** Create or replace a LiveFile by passing its Plain LSON shape as the whole operation value: `{ "liveblocksType": "LiveFile", "data": LiveFileData }`. Use the @@ -134,6 +141,39 @@ fails and no changes are applied. Use `test` to guard against concurrent updates ] ``` +## LiveText examples + +LiveText contents can currently only be replaced as a whole. Use a string for +plain text, or an array of segments where each segment is `[text]` or +`[text, attributes]`. + +```json +[ + { "op": "replace", "path": "/text", "value": "Hello" }, + { + "op": "replace", + "path": "/text", + "value": [["Hello", { "bold": true }], [" world"]] + } +] +``` + +Paths below a LiveText node, such as `/text/data/0/0`, are not supported. A +whole LiveText node can still be removed: + +```json +[{ "op": "remove", "path": "/text" }] +``` + +Use `test` against the whole LiveText node for optimistic checks. + +```json +[ + { "op": "test", "path": "/text", "value": [["Original"]] }, + { "op": "replace", "path": "/text", "value": "Updated" } +] +``` + ## LiveFile example After uploading a Storage file, use the returned metadata to create an immutable diff --git a/package.json b/package.json index c130684077d..3ce2ac1a7ea 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test:ci": "turbo run test:ci", "test:types": "turbo run test:types", "test:deps": "turbo run test:deps", + "typecheck": "turbo run typecheck", "lint": "turbo run lint", "lint:package": "turbo run lint:package", "format": "turbo run format", diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index e4b817a4143..0bdf7e2fff1 100644 --- a/packages/liveblocks-chat-sdk-adapter/package.json +++ b/packages/liveblocks-chat-sdk-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/chat-sdk-adapter", - "version": "3.23.1", + "version": "3.24.0", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -30,6 +30,7 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:ci": "vitest run --coverage", "test:watch": "vitest" diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 7f8cc327ed7..e200da81925 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.23.1", + "version": "3.24.0", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -30,6 +30,7 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", + "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "test:ci": "vitest run --passWithNoTests --coverage", "test:types": "vitest run --config ./vitest.config.typecheck.ts", diff --git a/packages/liveblocks-client/src/index.ts b/packages/liveblocks-client/src/index.ts index bd2559e9755..cbe6af38a7b 100644 --- a/packages/liveblocks-client/src/index.ts +++ b/packages/liveblocks-client/src/index.ts @@ -51,6 +51,10 @@ export type { LiveMapUpdate, LiveObjectUpdate, LiveStructure, + LiveTextAttributes, + LiveTextAttributesPatch, + LiveTextData, + LiveTextUpdate, LostConnectionEvent, Lson, LsonObject, @@ -91,6 +95,7 @@ export { LiveList, LiveMap, LiveObject, + LiveText, nanoid, shallow, stringifyCommentBody, diff --git a/packages/liveblocks-codemirror/README.md b/packages/liveblocks-codemirror/README.md new file mode 100644 index 00000000000..5352d2e977c --- /dev/null +++ b/packages/liveblocks-codemirror/README.md @@ -0,0 +1,56 @@ +

    + Liveblocks + Liveblocks +

    + +# `@liveblocks/codemirror` + +

    + NPM + Size + License +

    + +`@liveblocks/codemirror` provides [CodeMirror](https://codemirror.net/) plugins +that sync a document with Liveblocks Storage and display remote carets and +selections. + +## Installation + +``` +npm install @liveblocks/client @liveblocks/react @liveblocks/codemirror codemirror +``` + +## Documentation + +Read the +[documentation](https://liveblocks.io/docs/api-reference/liveblocks-codemirror) +for guides and API references. + +## Examples + +Explore our [collaborative examples](https://liveblocks.io/examples) to help you +get started. + +> All examples are open-source and live in this repository, within +> [`/examples`](../../examples). + +## Releases + +See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or +learn more about +[upcoming releases](https://github.com/liveblocks/liveblocks/milestones). + +## Community + +- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks + community, ask questions and share tips. +- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts, + and general Liveblocks tips. + +## License + +Licensed under the Apache License 2.0, Copyright © 2021-present +[Liveblocks](https://liveblocks.io). + +See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-codemirror/eslint.config.mjs b/packages/liveblocks-codemirror/eslint.config.mjs new file mode 100644 index 00000000000..d9c85c18afc --- /dev/null +++ b/packages/liveblocks-codemirror/eslint.config.mjs @@ -0,0 +1,26 @@ +import { makeConfig } from "@liveblocks/eslint-config"; +import commonRestrictedSyntax from "@liveblocks/eslint-config/restricted-syntax"; + +export default [ + ...makeConfig(), + { + rules: { + "no-restricted-syntax": ["error", ...commonRestrictedSyntax], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off", + }, + }, + { + files: ["src/**/__tests__/**"], + rules: { + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-floating-promises": "off", + }, + }, +]; diff --git a/packages/liveblocks-codemirror/package.json b/packages/liveblocks-codemirror/package.json new file mode 100644 index 00000000000..a40f294f1aa --- /dev/null +++ b/packages/liveblocks-codemirror/package.json @@ -0,0 +1,78 @@ +{ + "name": "@liveblocks/codemirror", + "version": "3.24.0", + "description": "CodeMirror collaboration plugins backed by Liveblocks.", + "license": "Apache-2.0", + "author": "Liveblocks Inc.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "module": "./dist/index.js", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist/**", + "README.md" + ], + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "format": "eslint --fix src/; prettier --write src/", + "lint": "eslint src/", + "lint:package": "publint --strict && attw --pack", + "start": "pnpm run dev", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "test:watch": "vitest" + }, + "dependencies": { + "@liveblocks/client": "workspace:*", + "@liveblocks/core": "workspace:*" + }, + "peerDependencies": { + "@codemirror/state": "^6", + "@codemirror/view": "^6" + }, + "devDependencies": { + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", + "@liveblocks/eslint-config": "workspace:*", + "@liveblocks/vitest-config": "workspace:*", + "eslint": "^9.39.4", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + }, + "sideEffects": false, + "bugs": { + "url": "https://github.com/liveblocks/liveblocks/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/liveblocks/liveblocks.git", + "directory": "packages/liveblocks-codemirror" + }, + "homepage": "https://liveblocks.io", + "keywords": [ + "codemirror", + "liveblocks", + "real-time", + "collaboration", + "collaborative", + "presence", + "crdts", + "synchronize", + "rooms", + "documents" + ] +} diff --git a/packages/liveblocks-codemirror/src/__tests__/presence-plugin.test.ts b/packages/liveblocks-codemirror/src/__tests__/presence-plugin.test.ts new file mode 100644 index 00000000000..c1f5d36743c --- /dev/null +++ b/packages/liveblocks-codemirror/src/__tests__/presence-plugin.test.ts @@ -0,0 +1,754 @@ +import { EditorSelection, EditorState, Transaction } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { LiveObject, Room } from "@liveblocks/client"; +import type { LiveText } from "@liveblocks/core"; +import { CrdtType, kInternal } from "@liveblocks/core"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, + prepareStorageTest, +} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup"; +import { + createLiveblocksPresencePlugin, + type LiveblocksCodemirrorSelection, +} from "../presence-plugin"; +import { createLiveblocksSyncPlugin } from "../sync-plugin"; + +type Presence = { + selection: LiveblocksCodemirrorSelection | null; +}; + +describe("createLiveblocksPresencePlugin", () => { + describe("local → presence", () => { + test("local edit broadcasts encoded selection", async () => { + const initialDoc = "abc"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const updatePresence = vi.spyOn(room, "updatePresence"); + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ...createLiveblocksPresencePlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ + selection: EditorSelection.cursor(2), + changes: { from: 2, to: 2, insert: "!" }, + }); + + const text = root.get("document"); + expect(updatePresence).toHaveBeenCalledWith({ + selection: { + anchor: text[kInternal].encodeIndex(3), + head: text[kInternal].encodeIndex(3), + version: text.version, + }, + }); + }); + + test("remote transactions do not broadcast selection", async () => { + const initialDoc = "abc"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const updatePresence = vi.spyOn(room, "updatePresence"); + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ...createLiveblocksPresencePlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + updatePresence.mockClear(); + + view.dispatch({ + changes: { from: 0, to: 0, insert: "Y" }, + annotations: [Transaction.remote.of(true)], + }); + + expect(updatePresence).not.toHaveBeenCalled(); + }); + + test("destroy clears presence", async () => { + const initialDoc = "abc"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const updatePresence = vi.spyOn(room, "updatePresence"); + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: createLiveblocksPresencePlugin( + room, + root.get("document") + ), + }), + parent, + }); + + updatePresence.mockClear(); + view.destroy(); + parent.remove(); + + expect(updatePresence).toHaveBeenCalledWith({ selection: null }); + }); + }); + + describe("remote → layers", () => { + test("displays existing remote cursors on mount", async () => { + const initialDoc = "a\nb"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const text = storage.root.get("document"); + room.updatePresence({ + selection: { + anchor: text[kInternal].encodeIndex(1), + head: text[kInternal].encodeIndex(1), + version: text.version, + }, + }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()).toHaveLength(1); + }); + + const parentB = document.createElement("div"); + parentB.style.width = "800px"; + parentB.style.height = "400px"; + document.body.appendChild(parentB); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewB.destroy(); + parentB.remove(); + }); + + const layoutRect = { + left: 0, + top: 0, + right: 800, + bottom: 400, + width: 800, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + vi.spyOn(viewB.scrollDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB.contentDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB, "coordsAtPos").mockImplementation((pos: number) => ({ + left: 8 + pos * 8, + right: 9 + pos * 8, + top: 20, + bottom: 36, + })); + + await new Promise((resolve) => { + viewB.requestMeasure({ read: () => null, write: () => resolve() }); + }); + + expect( + viewB.scrollDOM.querySelector(".lb-remote-caretLayer .lb-remote-caret") + ).not.toBeNull(); + }); + + test("remote presence renders carets in a layer, not inline widgets", async () => { + const initialDoc = "a\nb"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentB = document.createElement("div"); + parentB.style.width = "800px"; + parentB.style.height = "400px"; + document.body.appendChild(parentB); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewB.destroy(); + parentB.remove(); + }); + + const layoutRect = { + left: 0, + top: 0, + right: 800, + bottom: 400, + width: 800, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + vi.spyOn(viewB.scrollDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB.contentDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB, "coordsAtPos").mockImplementation((pos: number) => ({ + left: 8 + pos * 8, + right: 9 + pos * 8, + top: 20, + bottom: 36, + })); + + const text = storage.root.get("document"); + room.updatePresence({ + selection: { + anchor: text[kInternal].encodeIndex(1), + head: text[kInternal].encodeIndex(1), + version: text.version, + }, + }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()).toHaveLength(1); + }); + + await new Promise((resolve) => { + viewB.requestMeasure({ read: () => null, write: () => resolve() }); + }); + + expect(viewB.contentDOM.querySelector(".lb-remote-caret")).toBeNull(); + expect(viewB.contentDOM.querySelector(".cm-widgetBuffer")).toBeNull(); + expect( + viewB.scrollDOM.querySelector(".lb-remote-caretLayer .lb-remote-caret") + ).not.toBeNull(); + }); + + test("remote range selection renders in the selection layer", async () => { + const initialDoc = "Hello, world"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentB = document.createElement("div"); + parentB.style.width = "800px"; + parentB.style.height = "400px"; + document.body.appendChild(parentB); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewB.destroy(); + parentB.remove(); + }); + + const layoutRect = { + left: 0, + top: 0, + right: 800, + bottom: 400, + width: 800, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + vi.spyOn(viewB.scrollDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB.contentDOM, "getBoundingClientRect").mockReturnValue( + layoutRect + ); + vi.spyOn(viewB, "coordsAtPos").mockImplementation((pos: number) => ({ + left: 8 + pos * 8, + right: 9 + pos * 8, + top: 20, + bottom: 36, + })); + + const text = storage.root.get("document"); + room.updatePresence({ + selection: { + anchor: text[kInternal].encodeIndex(0), + head: text[kInternal].encodeIndex(5), + version: text.version, + }, + }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()).toHaveLength(1); + }); + + await new Promise((resolve) => { + viewB.requestMeasure({ read: () => null, write: () => resolve() }); + }); + + expect(viewB.contentDOM.querySelector(".lb-remote-selection")).toBeNull(); + expect( + viewB.scrollDOM.querySelector( + ".lb-remote-selectionLayer .lb-remote-selection" + ) + ).not.toBeNull(); + }); + + test("cleared remote presence removes layer carets", async () => { + const initialDoc = "abc"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentB = document.createElement("div"); + document.body.appendChild(parentB); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewB.destroy(); + parentB.remove(); + }); + + const text = storage.root.get("document"); + room.updatePresence({ + selection: { + anchor: text[kInternal].encodeIndex(1), + head: text[kInternal].encodeIndex(1), + version: text.version, + }, + }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()[0]?.presence.selection).not.toBeNull(); + }); + + room.updatePresence({ selection: null }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()[0]?.presence.selection).toBeNull(); + }); + + await new Promise((resolve) => { + viewB.requestMeasure({ read: () => null, write: () => resolve() }); + }); + + expect( + viewB.scrollDOM.querySelector(".lb-remote-caretLayer .lb-remote-caret") + ).toBeNull(); + }); + + test("undecodable presence is rebased after storage catches up", async () => { + const initialDoc = "abc"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentA = document.createElement("div"); + const parentB = document.createElement("div"); + document.body.appendChild(parentA); + document.body.appendChild(parentB); + + const viewA = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, storage.root.get("document")), + ...createLiveblocksPresencePlugin( + room, + storage.root.get("document") + ), + ], + }), + parent: parentA, + }); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewA.destroy(); + viewB.destroy(); + parentA.remove(); + parentB.remove(); + }); + + const text = refStorage.root.get("document"); + const decodeIndex = vi + .spyOn(text[kInternal], "decodeIndex") + .mockReturnValueOnce(null) + .mockReturnValueOnce(null); + + room.updatePresence({ + selection: { + anchor: storage.root.get("document")[kInternal].encodeIndex(2), + head: storage.root.get("document")[kInternal].encodeIndex(2), + version: storage.root.get("document").version + 1, + }, + }); + + await vi.waitFor(() => { + expect(decodeIndex).toHaveBeenCalled(); + }); + + decodeIndex.mockRestore(); + + viewA.dispatch({ changes: { from: 0, to: 0, insert: "Z" } }); + + await vi.waitFor(() => { + expect(refStorage.root.get("document").toString()).toBe("Zabc"); + }); + + expect(viewA).toBeDefined(); + expect(viewB).toBeDefined(); + }); + }); + + describe("multi-client", () => { + test("deleting a newline adjacent to a remote caret succeeds", async () => { + const initialDoc = "a\nb"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentA = document.createElement("div"); + const parentB = document.createElement("div"); + document.body.appendChild(parentA); + document.body.appendChild(parentB); + + const viewA = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, storage.root.get("document")), + ...createLiveblocksPresencePlugin( + room, + storage.root.get("document") + ), + ], + }), + parent: parentA, + }); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ...createLiveblocksPresencePlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewA.destroy(); + viewB.destroy(); + parentA.remove(); + parentB.remove(); + }); + + const text = storage.root.get("document"); + room.updatePresence({ + selection: { + anchor: text[kInternal].encodeIndex(1), + head: text[kInternal].encodeIndex(1), + version: text.version, + }, + }); + + await vi.waitFor(() => { + expect(refRoom.getOthers()).toHaveLength(1); + }); + + viewB.dispatch({ + selection: EditorSelection.cursor(2), + changes: { from: 1, to: 2 }, + }); + + const editorTextB = viewB.state.doc.toString(); + + expect(editorTextB).toBe("ab"); + expect(viewB.contentDOM.querySelector(".cm-widgetBuffer")).toBeNull(); + + expect(viewA).toBeDefined(); + }); + }); +}); diff --git a/packages/liveblocks-codemirror/src/__tests__/sync-plugin.test.ts b/packages/liveblocks-codemirror/src/__tests__/sync-plugin.test.ts new file mode 100644 index 00000000000..4ab68caad1e --- /dev/null +++ b/packages/liveblocks-codemirror/src/__tests__/sync-plugin.test.ts @@ -0,0 +1,1650 @@ +import { + ChangeSet, + EditorSelection, + EditorState, + Transaction, +} from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { LiveObject, Room } from "@liveblocks/client"; +import type { LiveText } from "@liveblocks/core"; +import { CrdtType, kInternal, OpCode } from "@liveblocks/core"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, + prepareStorageTest, +} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup"; +import { createLiveblocksSyncPlugin, isAdjacent } from "../sync-plugin"; + +describe("createLiveblocksSyncPlugin", () => { + describe("local → storage", () => { + test("local edit updates LiveText storage", async () => { + const initialDoc = "Hello, world"; + + // Connect a real room to the mock WebSocket server with a LiveText node. + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + // Simulate the user typing "!" at the end of the document. + view.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + }); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("Hello, world!"); + expect(editorText).toBe("Hello, world!"); + expect(editorText).toBe(storageText); + }); + + test("local storage mutations are not echoed back into the editor", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + // Mutate storage directly on this client. The plugin should ignore it — + // the editor already owns local changes. + root.get("document").insert(0, "X"); + + expect(root.get("document").toString()).toBe("XHello, world"); + expect(view.state.doc.toString()).toBe("Hello, world"); + }); + + test("multi-change local edit updates LiveText storage", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + // One transaction, two changes — exercises offset tracking in the plugin. + view.dispatch({ + changes: [ + { from: 0, to: 5, insert: "Hi" }, + { from: 7, to: 12, insert: "everyone" }, + ], + }); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("Hi, everyone"); + expect(editorText).toBe("Hi, everyone"); + expect(editorText).toBe(storageText); + }); + }); + + describe("remote → editor", () => { + test("remote insert updates the editor document", async () => { + const initialDoc = "Hello, world"; + + const { room, root, applyRemoteOperations } = + (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array<{ type: "insert"; index: number; text: string }>; + }> + ) => void; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: initialDoc.length, text: "!" }], + }, + ]); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("Hello, world!"); + expect(editorText).toBe("Hello, world!"); + expect(editorText).toBe(storageText); + }); + + test("remote delete updates the editor document", async () => { + const initialDoc = "Hello, world"; + + const { room, root, applyRemoteOperations } = + (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array<{ type: "delete"; index: number; length: number }>; + }> + ) => void; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "delete", index: 0, length: 1 }], + }, + ]); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("ello, world"); + expect(editorText).toBe("ello, world"); + expect(editorText).toBe(storageText); + }); + + test("remote multi-op update updates the editor document", async () => { + const initialDoc = "Hello, world"; + + const { room, root, applyRemoteOperations } = + (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array< + | { type: "insert"; index: number; text: string } + | { type: "delete"; index: number; length: number } + >; + }> + ) => void; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + // One remote update with delete + insert (replace "Hello" with "Hi"). + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [ + { type: "delete", index: 0, length: 5 }, + { type: "insert", index: 0, text: "Hi" }, + ], + }, + ]); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("Hi, world"); + expect(editorText).toBe("Hi, world"); + expect(editorText).toBe(storageText); + }); + + test("remote editor updates do not write back to storage", async () => { + const initialDoc = "Hello, world"; + + const { room, root, applyRemoteOperations } = + (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array<{ type: "insert"; index: number; text: string }>; + }> + ) => void; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const text = root.get("document"); + const replaceSpy = vi.spyOn(text, "replace"); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [createLiveblocksSyncPlugin(room, root.get("document"))], + }), + parent, + }); + + onTestFinished(() => { + replaceSpy.mockRestore(); + view.destroy(); + parent.remove(); + }); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: initialDoc.length, text: "!" }], + }, + ]); + + // Remote ops update storage without going through LiveText.replace. + // The plugin must not write the editor change back through replace either. + expect(replaceSpy.mock.calls.length).toBe(0); + expect(text.toString()).toBe("Hello, world!"); + expect(view.state.doc.toString()).toBe("Hello, world!"); + }); + }); + + describe("multi-client", () => { + test("edits on client A appear in client B's editor", async () => { + const initialDoc = "Hello, world"; + + const { room, refRoom, storage, refStorage } = (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + }; + + const parentA = document.createElement("div"); + const parentB = document.createElement("div"); + document.body.appendChild(parentA); + document.body.appendChild(parentB); + + const viewA = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, storage.root.get("document")), + ], + }), + parent: parentA, + }); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewA.destroy(); + viewB.destroy(); + parentA.remove(); + parentB.remove(); + }); + + viewA.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + }); + + const storageText = storage.root.get("document").toString(); + const refStorageText = refStorage.root.get("document").toString(); + const editorTextA = viewA.state.doc.toString(); + const editorTextB = viewB.state.doc.toString(); + + expect(storageText).toBe("Hello, world!"); + expect(refStorageText).toBe("Hello, world!"); + expect(editorTextA).toBe("Hello, world!"); + expect(editorTextB).toBe("Hello, world!"); + expect(editorTextA).toBe(editorTextB); + }); + + test("concurrent edits on both clients converge", async () => { + const initialDoc = "Hello, world"; + + const { room, refRoom, storage, refStorage, applyRemoteOperations } = + (await prepareStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + refRoom: Room; + storage: { root: LiveObject<{ document: LiveText }> }; + refStorage: { root: LiveObject<{ document: LiveText }> }; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array<{ type: "insert"; index: number; text: string }>; + }> + ) => void; + }; + + const parentA = document.createElement("div"); + const parentB = document.createElement("div"); + document.body.appendChild(parentA); + document.body.appendChild(parentB); + + const viewA = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, storage.root.get("document")), + ], + }), + parent: parentA, + }); + + const viewB = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin( + refRoom, + refStorage.root.get("document") + ), + ], + }), + parent: parentB, + }); + + onTestFinished(() => { + viewA.destroy(); + viewB.destroy(); + parentA.remove(); + parentB.remove(); + }); + + // Client A inserts at the end. prepareStorageTest relays subject ops to refRoom. + viewA.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + }); + + // Client B inserts at the start on its own connection. + viewB.dispatch({ + changes: { from: 0, insert: "X" }, + }); + + // Mock server only relays subject → ref, not ref → subject. Apply B's op on A. + const baseVersion = storage.root.get("document").version; + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion, + version: baseVersion + 1, + ops: [{ type: "insert", index: 0, text: "X" }], + }, + ]); + + const storageTextA = storage.root.get("document").toString(); + const storageTextB = refStorage.root.get("document").toString(); + const editorTextA = viewA.state.doc.toString(); + const editorTextB = viewB.state.doc.toString(); + + expect(storageTextA).toBe("XHello, world!"); + expect(storageTextB).toBe("XHello, world!"); + expect(editorTextA).toBe("XHello, world!"); + expect(editorTextB).toBe("XHello, world!"); + expect(editorTextA).toBe(editorTextB); + }); + }); + + describe("undo / redo", () => { + describe("document", () => { + test("undo reverts the editor and storage", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + }); + + expect(root.get("document").toString()).toBe("Hello, world!"); + expect(view.state.doc.toString()).toBe("Hello, world!"); + + room.history.resume(); + room.history.undo(); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe(initialDoc); + expect(editorText).toBe(initialDoc); + expect(editorText).toBe(storageText); + }); + + test("redo restores the editor and storage", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + }); + + expect(root.get("document").toString()).toBe("Hello, world!"); + expect(view.state.doc.toString()).toBe("Hello, world!"); + + room.history.resume(); + room.history.undo(); + + expect(root.get("document").toString()).toBe(initialDoc); + expect(view.state.doc.toString()).toBe(initialDoc); + + room.history.resume(); + room.history.redo(); + + const storageText = root.get("document").toString(); + const editorText = view.state.doc.toString(); + + expect(storageText).toBe("Hello, world!"); + expect(editorText).toBe("Hello, world!"); + expect(editorText).toBe(storageText); + }); + }); + + describe("grouping", () => { + test("consecutive typing is undone in one step", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + for (const char of "!!") { + view.dispatch({ + changes: { + from: view.state.doc.length, + insert: char, + }, + selection: EditorSelection.single(view.state.doc.length + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(Date.now()), + ], + }); + } + + expect(view.state.doc.toString()).toBe("Hello, world!!"); + + room.history.resume(); + room.history.undo(); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(root.get("document").toString()).toBe(initialDoc); + }); + + test("non-adjacent edits undo separately", async () => { + const initialDoc = "012345678901234567890"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + const time = Date.now(); + view.dispatch({ + changes: { from: 0, insert: "A" }, + selection: EditorSelection.single(1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(time), + ], + }); + view.dispatch({ + changes: { from: view.state.doc.length, insert: "B" }, + selection: EditorSelection.single(view.state.doc.length + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(time + 1), + ], + }); + + expect(view.state.doc.toString()).toBe("A012345678901234567890B"); + + room.history.resume(); + room.history.undo(); + expect(view.state.doc.toString()).toBe("A012345678901234567890"); + + room.history.undo(); + expect(view.state.doc.toString()).toBe(initialDoc); + }); + + test("selection change starts a new undo group", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + const time = Date.now(); + view.dispatch({ + changes: { from: initialDoc.length, insert: "!" }, + selection: EditorSelection.single(initialDoc.length + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(time), + ], + }); + view.dispatch({ + selection: EditorSelection.single(0), + annotations: [ + Transaction.userEvent.of("select.pointer"), + Transaction.time.of(time + 1), + ], + }); + view.dispatch({ + changes: { from: 0, insert: "X" }, + selection: EditorSelection.single(1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(time + 2), + ], + }); + + expect(view.state.doc.toString()).toBe("XHello, world!"); + + room.history.resume(); + room.history.undo(); + expect(view.state.doc.toString()).toBe("Hello, world!"); + + room.history.undo(); + expect(view.state.doc.toString()).toBe(initialDoc); + }); + + test("paste starts a new undo group", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + const cursor = initialDoc.length; + const time = Date.now(); + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(time), + ], + }); + view.dispatch({ + changes: { from: cursor + 1, insert: "?" }, + selection: EditorSelection.single(cursor + 2), + annotations: [ + Transaction.userEvent.of("input.paste"), + Transaction.time.of(time + 1), + ], + }); + + expect(view.state.doc.toString()).toBe("Hello, world!?"); + + room.history.resume(); + room.history.undo(); + expect(view.state.doc.toString()).toBe("Hello, world!"); + + room.history.undo(); + expect(view.state.doc.toString()).toBe(initialDoc); + }); + + test("a 500ms gap between edits starts a new undo group", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + view.destroy(); + parent.remove(); + }); + + const cursor = initialDoc.length; + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(0), + ], + }); + view.dispatch({ + changes: { + from: view.state.doc.length, + insert: "?", + }, + selection: EditorSelection.single(view.state.doc.length + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(600), + ], + }); + + expect(view.state.doc.toString()).toBe("Hello, world!?"); + + room.history.resume(); + room.history.undo(); + expect(view.state.doc.toString()).toBe("Hello, world!"); + + room.history.undo(); + expect(view.state.doc.toString()).toBe(initialDoc); + }); + + test("IME compose merges into the previous group", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + const cursor = initialDoc.length; + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + annotations: [ + Transaction.userEvent.of("input.type"), + Transaction.time.of(0), + ], + }); + view.dispatch({ + changes: { from: cursor + 1, insert: "?" }, + selection: EditorSelection.single(cursor + 2), + annotations: [ + Transaction.userEvent.of("input.type.compose"), + Transaction.time.of(600), + ], + }); + + expect(view.state.doc.toString()).toBe("Hello, world!?"); + + room.history.resume(); + room.history.undo(); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(root.get("document").toString()).toBe(initialDoc); + }); + }); + + describe("selection", () => { + test("undo restores the selection from before the edit", async () => { + const initialDoc = "Hello, world"; + const cursor = initialDoc.length; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ selection: EditorSelection.single(cursor) }); + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + }); + + room.history.resume(); + room.history.undo(); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(view.state.selection.main.anchor).toBe(cursor); + expect(view.state.selection.main.head).toBe(cursor); + }); + + test("redo restores the selection from after the edit", async () => { + const initialDoc = "Hello, world"; + const cursor = initialDoc.length; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ selection: EditorSelection.single(cursor) }); + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + }); + + const afterCursor = cursor + 1; + + room.history.resume(); + room.history.undo(); + room.history.resume(); + room.history.redo(); + + expect(view.state.doc.toString()).toBe("Hello, world!"); + expect(view.state.selection.main.anchor).toBe(afterCursor); + expect(view.state.selection.main.head).toBe(afterCursor); + }); + + test("undo clamps the selection when the document shrinks", async () => { + const initialDoc = "Hello, world"; + const cursor = initialDoc.length; + const deleteFrom = 5; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + // Cursor at end, then delete ", world". Undo restores the pre-delete + // selection (past the shorter doc) and must clamp it. + view.dispatch({ selection: EditorSelection.single(cursor) }); + view.dispatch({ + changes: { from: deleteFrom, to: cursor, insert: "" }, + selection: EditorSelection.single(deleteFrom), + }); + + expect(view.state.doc.toString()).toBe("Hello"); + expect(view.state.selection.main.head).toBe(deleteFrom); + + room.history.resume(); + room.history.undo(); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(view.state.selection.main.anchor).toBe(cursor); + expect(view.state.selection.main.head).toBe(cursor); + expect(view.state.selection.main.head).toBeLessThanOrEqual( + view.state.doc.length + ); + }); + + test("peer remote edit rebases stored history selections before undo", async () => { + const initialDoc = "Hello, world"; + const cursor = initialDoc.length; + + const { room, root, applyRemoteOperations } = + (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + applyRemoteOperations: ( + ops: Array<{ + type: typeof OpCode.UPDATE_TEXT; + id: string; + baseVersion: number; + version: number; + ops: Array<{ type: "insert"; index: number; text: string }>; + }> + ) => void; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ selection: EditorSelection.single(cursor) }); + view.dispatch({ + changes: { from: cursor, insert: "!" }, + selection: EditorSelection.single(cursor + 1), + }); + + expect(view.state.doc.toString()).toBe("Hello, world!"); + + const baseVersion = root.get("document").version; + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion, + version: baseVersion + 1, + ops: [{ type: "insert", index: 0, text: "X" }], + }, + ]); + + expect(view.state.doc.toString()).toBe("XHello, world!"); + + room.history.resume(); + room.history.undo(); + + // Local "!" undone; peer "X" remains. Cursor was at 12 before the local + // edit, rebased to 13 after the peer insert at 0. + expect(view.state.doc.toString()).toBe("XHello, world"); + expect(view.state.selection.main.anchor).toBe(13); + expect(view.state.selection.main.head).toBe(13); + }); + + test("replace edit restores selection on undo and redo", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ selection: EditorSelection.single(0, 5) }); + view.dispatch({ + changes: { from: 0, to: 5, insert: "Hi" }, + selection: EditorSelection.single(2), + }); + + expect(view.state.doc.toString()).toBe("Hi, world"); + expect(view.state.selection.main.anchor).toBe(2); + expect(view.state.selection.main.head).toBe(2); + + room.history.resume(); + room.history.undo(); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(view.state.selection.main.anchor).toBe(0); + expect(view.state.selection.main.head).toBe(5); + + room.history.resume(); + room.history.redo(); + + expect(view.state.doc.toString()).toBe("Hi, world"); + expect(view.state.selection.main.anchor).toBe(2); + expect(view.state.selection.main.head).toBe(2); + }); + + test("range undo cannot restore selection via decodeIndex or selection.map", async () => { + const initialDoc = "Hello, world"; + + const { room, root } = (await prepareIsolatedStorageTest( + [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "document", + data: [[initialDoc]], + version: 0, + }, + ], + ], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document: LiveText }>; + }; + + const liveText = root.get("document"); + const parent = document.createElement("div"); + document.body.appendChild(parent); + + const view = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + createLiveblocksSyncPlugin(room, root.get("document")), + ], + }), + parent, + }); + + onTestFinished(() => { + view.destroy(); + parent.remove(); + }); + + view.dispatch({ selection: EditorSelection.single(0, 5) }); + view.dispatch({ + changes: { from: 0, to: 5, insert: "Hi" }, + selection: EditorSelection.single(2), + }); + + let undoChanges = ChangeSet.empty(0); + + const sub = room.subscribe( + root, + (updates) => { + for (const update of updates) { + if (update.type !== "LiveText" || update.node !== liveText) { + continue; + } + const source = update.source; + if (source.origin !== "local" || source.via !== "undo") { + continue; + } + + let changes = ChangeSet.empty(view.state.doc.length); + let currentLength = view.state.doc.length; + for (const change of update.updates) { + const step = + change.type === "insert" + ? ChangeSet.of( + [{ from: change.index, insert: change.text }], + currentLength + ) + : ChangeSet.of( + [ + { + from: change.index, + to: change.index + change.length, + }, + ], + currentLength + ); + changes = changes.compose(step); + currentLength = changes.newLength; + } + undoChanges = changes; + } + }, + { isDeep: true } + ); + + room.history.resume(); + room.history.undo(); + sub(); + + const mappedCurrent = view.state.selection.map(undoChanges, 1); + + expect(view.state.doc.toString()).toBe(initialDoc); + expect(view.state.selection.main.anchor).toBe(0); + expect(view.state.selection.main.head).toBe(5); + expect({ + decodeAnchor: liveText[kInternal].decodeIndex(0, 0), + decodeHead: liveText[kInternal].decodeIndex(5, 0), + mapCurrentSelection: { + anchor: mappedCurrent.main.anchor, + head: mappedCurrent.main.head, + }, + }).toEqual({ + decodeAnchor: 5, + decodeHead: 5, + mapCurrentSelection: { anchor: 0, head: 8 }, + }); + }); + }); + }); + + describe("isAdjacent", () => { + test("returns true when change ranges overlap", () => { + const prev = ChangeSet.of([{ from: 2, to: 4, insert: "x" }], 10); + const next = ChangeSet.of([{ from: 3, insert: "y" }], 11); + + expect(isAdjacent(prev, next)).toBe(true); + }); + + test("returns false when change ranges are separate", () => { + const prev = ChangeSet.of([{ from: 1, insert: "x" }], 10); + const next = ChangeSet.of([{ from: 8, insert: "y" }], 11); + + expect(isAdjacent(prev, next)).toBe(false); + }); + }); +}); diff --git a/packages/liveblocks-codemirror/src/index.ts b/packages/liveblocks-codemirror/src/index.ts new file mode 100644 index 00000000000..ce100052c4c --- /dev/null +++ b/packages/liveblocks-codemirror/src/index.ts @@ -0,0 +1,10 @@ +import { detectDupes } from "@liveblocks/core"; + +import { createLiveblocksPresencePlugin } from "./presence-plugin"; +import { createLiveblocksSyncPlugin } from "./sync-plugin"; +import { PKG_FORMAT, PKG_NAME, PKG_VERSION } from "./version"; + +detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); + +export { createLiveblocksPresencePlugin, createLiveblocksSyncPlugin }; +export type { LiveblocksCodemirrorSelection } from "./presence-plugin"; diff --git a/packages/liveblocks-codemirror/src/presence-plugin.ts b/packages/liveblocks-codemirror/src/presence-plugin.ts new file mode 100644 index 00000000000..a6ca9fac55f --- /dev/null +++ b/packages/liveblocks-codemirror/src/presence-plugin.ts @@ -0,0 +1,482 @@ +import { + EditorSelection, + type Extension, + StateEffect, + StateField, + Transaction, +} from "@codemirror/state"; +import { + Direction, + type EditorView, + layer, + type LayerMarker, + RectangleMarker, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import type { LsonObject, Room } from "@liveblocks/client"; +import { kInternal, type LiveText } from "@liveblocks/core"; + +import { clamp } from "./utils"; + +type RemoteSelection = { + connectionId: number; + anchor: number; + head: number; + name?: string; + color?: string; +}; + +export type LiveblocksCodemirrorSelection = { + anchor: number; + head: number; + version: number; +}; + +type LiveblocksCodemirrorPresence = { + selection: LiveblocksCodemirrorSelection | null; +}; + +type LiveblocksCodemirrorUserMeta = { + id?: string; + info?: { + name?: string; + color?: string; + }; +}; + +class RemoteSelectionMarker implements LayerMarker { + constructor( + readonly left: number, + readonly top: number, + readonly width: number, + readonly height: number, + readonly color: string + ) {} + + eq(other: LayerMarker): boolean { + return ( + other instanceof RemoteSelectionMarker && + other.left === this.left && + other.top === this.top && + other.width === this.width && + other.height === this.height && + other.color === this.color + ); + } + + draw(): HTMLElement { + const element = document.createElement("div"); + element.className = "lb-remote-selection"; + element.style.setProperty("--lb-remote-color", this.color); + element.style.left = `${this.left}px`; + element.style.top = `${this.top}px`; + element.style.width = `${this.width}px`; + element.style.height = `${this.height}px`; + return element; + } + + update(element: HTMLElement, prev: LayerMarker): boolean { + if (!(prev instanceof RemoteSelectionMarker)) return false; + if (prev.color !== this.color) return false; + element.style.left = `${this.left}px`; + element.style.top = `${this.top}px`; + element.style.width = `${this.width}px`; + element.style.height = `${this.height}px`; + return true; + } +} + +class RemoteCaretMarker implements LayerMarker { + constructor( + readonly left: number, + readonly top: number, + readonly height: number, + readonly selection: RemoteSelection + ) {} + + eq(other: LayerMarker): boolean { + return ( + other instanceof RemoteCaretMarker && + other.selection.connectionId === this.selection.connectionId && + other.left === this.left && + other.top === this.top && + other.height === this.height && + other.selection.color === this.selection.color + ); + } + + draw(): HTMLElement { + const element = document.createElement("div"); + element.className = "lb-remote-caret"; + element.style.setProperty( + "--lb-remote-color", + this.selection.color ?? "#888888" + ); + element.style.left = `${this.left}px`; + element.style.top = `${this.top}px`; + element.style.height = `${this.height}px`; + element.setAttribute("aria-hidden", "true"); + + return element; + } + + update(element: HTMLElement, prev: LayerMarker): boolean { + if (!(prev instanceof RemoteCaretMarker)) return false; + if (prev.selection.connectionId !== this.selection.connectionId) + return false; + element.style.setProperty( + "--lb-remote-color", + this.selection.color ?? "#888888" + ); + element.style.left = `${this.left}px`; + element.style.top = `${this.top}px`; + element.style.height = `${this.height}px`; + return true; + } +} + +export function createLiveblocksPresencePlugin( + room: Room< + LiveblocksCodemirrorPresence, + LsonObject, + LiveblocksCodemirrorUserMeta + >, + text: LiveText +): Extension[] { + const upsertRemoteSelections = StateEffect.define>(); + const removeRemoteSelections = StateEffect.define>(); + + const remoteSelectionsState = StateField.define>({ + create(state) { + const selections: RemoteSelection[] = []; + + for (const user of room.getOthers()) { + const presenceSelection = user.presence.selection; + if (presenceSelection === null || presenceSelection === undefined) { + continue; + } + + const anchor = text[kInternal].decodeIndex( + presenceSelection.anchor, + presenceSelection.version + ); + const head = text[kInternal].decodeIndex( + presenceSelection.head, + presenceSelection.version + ); + if (head === null || anchor === null) continue; + + selections.push({ + connectionId: user.connectionId, + anchor: clamp(anchor, { min: 0, max: state.doc.length }), + head: clamp(head, { min: 0, max: state.doc.length }), + name: user.info?.name, + color: user.info?.color, + }); + } + + return selections; + }, + update(selections, tr) { + let nextSelections = selections; + + if (tr.docChanged) { + nextSelections = nextSelections.map((selection) => ({ + ...selection, + anchor: clamp(tr.changes.mapPos(selection.anchor, 1), { + min: 0, + max: tr.newDoc.length, + }), + head: clamp(tr.changes.mapPos(selection.head, 1), { + min: 0, + max: tr.newDoc.length, + }), + })); + } + + for (const effect of tr.effects) { + if (effect.is(upsertRemoteSelections)) { + nextSelections = [ + ...new Map( + [...nextSelections, ...effect.value].map((sel) => [ + sel.connectionId, + { + ...sel, + anchor: clamp(sel.anchor, { + min: 0, + max: tr.newDoc.length, + }), + head: clamp(sel.head, { min: 0, max: tr.newDoc.length }), + }, + ]) + ).values(), + ]; + } else if (effect.is(removeRemoteSelections)) { + nextSelections = nextSelections.filter( + (selection) => !effect.value.has(selection.connectionId) + ); + } + } + + return nextSelections; + }, + }); + + function createRemoteSelectionMarkers(view: EditorView): LayerMarker[] { + const selections = view.state.field(remoteSelectionsState); + const markers: LayerMarker[] = []; + + for (const selection of selections) { + const from = clamp(Math.min(selection.anchor, selection.head), { + min: 0, + max: view.state.doc.length, + }); + const to = clamp(Math.max(selection.anchor, selection.head), { + min: 0, + max: view.state.doc.length, + }); + if (from === to) continue; + + const color = selection.color ?? "#888888"; + for (const rect of RectangleMarker.forRange( + view, + "lb-remote-selection", + EditorSelection.range(from, to) + )) { + if (rect.width === null) continue; + markers.push( + new RemoteSelectionMarker( + rect.left, + rect.top, + rect.width, + rect.height, + color + ) + ); + } + } + + return markers; + } + + function createRemoteCaretMarkers(view: EditorView): LayerMarker[] { + const selections = view.state.field(remoteSelectionsState); + const markers: LayerMarker[] = []; + const scrollRect = view.scrollDOM.getBoundingClientRect(); + const originLeft = + (view.textDirection === Direction.LTR + ? scrollRect.left + : scrollRect.right - view.scrollDOM.clientWidth * view.scaleX) - + view.scrollDOM.scrollLeft * view.scaleX; + const originTop = scrollRect.top - view.scrollDOM.scrollTop * view.scaleY; + + for (const selection of selections) { + const head = clamp(selection.head, { + min: 0, + max: view.state.doc.length, + }); + const coords = view.coordsAtPos(head, head <= selection.anchor ? -1 : 1); + if (coords === null) continue; + + markers.push( + new RemoteCaretMarker( + coords.left - originLeft, + coords.top - originTop, + coords.bottom - coords.top, + selection + ) + ); + } + + return markers; + } + + const shouldRedrawRemotePresence = (update: ViewUpdate) => + update.docChanged || + update.viewportChanged || + update.geometryChanged || + update.transactions.some((tr) => + tr.effects.some( + (effect) => + effect.is(upsertRemoteSelections) || effect.is(removeRemoteSelections) + ) + ); + + return [ + remoteSelectionsState, + layer({ + above: false, + class: "lb-remote-selectionLayer", + markers: createRemoteSelectionMarkers, + update: shouldRedrawRemotePresence, + }), + layer({ + above: true, + class: "lb-remote-caretLayer", + markers: createRemoteCaretMarkers, + update: shouldRedrawRemotePresence, + }), + ViewPlugin.fromClass( + class { + private pendingSelectionsByConnectionId = new Map< + number, + { + anchor: number; + head: number; + version: number; + name?: string; + color?: string; + } + >(); + private unsubscribeFromPresenceUpdates: () => void; + private unsubscribeFromStorageUpdates: () => void; + + constructor(private view: EditorView) { + this.unsubscribeFromStorageUpdates = room.subscribe( + text, + () => { + if (this.pendingSelectionsByConnectionId.size === 0) { + return; + } + const rebasedSelection: Array = []; + for (const [ + connectionId, + selection, + ] of this.pendingSelectionsByConnectionId.entries()) { + const anchor = text[kInternal].decodeIndex( + selection.anchor, + selection.version + ); + const head = text[kInternal].decodeIndex( + selection.head, + selection.version + ); + if (anchor === null || head === null) continue; + + this.pendingSelectionsByConnectionId.delete(connectionId); + rebasedSelection.push({ + connectionId, + anchor, + head, + name: selection.name, + color: selection.color, + }); + } + if (rebasedSelection.length > 0) { + this.view.dispatch({ + effects: upsertRemoteSelections.of(rebasedSelection), + }); + } + }, + { isDeep: true } + ); + + this.unsubscribeFromPresenceUpdates = room.subscribe( + "others", + (others) => { + const rebasedSelection: Array = []; + const connections = new Set(); + const connectionIdsToRemove = new Set(); + + for (const user of others) { + connections.add(user.connectionId); + if (user.presence.selection === null) { + this.pendingSelectionsByConnectionId.delete( + user.connectionId + ); + connectionIdsToRemove.add(user.connectionId); + continue; + } + + const anchor = text[kInternal].decodeIndex( + user.presence.selection.anchor, + user.presence.selection.version + ); + const head = text[kInternal].decodeIndex( + user.presence.selection.head, + user.presence.selection.version + ); + if (head === null || anchor === null) { + this.pendingSelectionsByConnectionId.set(user.connectionId, { + anchor: user.presence.selection.anchor, + head: user.presence.selection.head, + version: user.presence.selection.version, + name: user.info?.name, + color: user.info?.color, + }); + continue; + } + + this.pendingSelectionsByConnectionId.delete(user.connectionId); + rebasedSelection.push({ + connectionId: user.connectionId, + anchor, + head, + name: user.info?.name, + color: user.info?.color, + }); + } + + for (const selection of this.view.state.field( + remoteSelectionsState + )) { + if (!connections.has(selection.connectionId)) { + connectionIdsToRemove.add(selection.connectionId); + } + } + for (const connectionId of this.pendingSelectionsByConnectionId.keys()) { + if (!connections.has(connectionId)) { + this.pendingSelectionsByConnectionId.delete(connectionId); + connectionIdsToRemove.add(connectionId); + } + } + + const effects: Array< + StateEffect | Set> + > = []; + + if (rebasedSelection.length > 0) { + effects.push(upsertRemoteSelections.of(rebasedSelection)); + } + if (connectionIdsToRemove.size > 0) { + effects.push(removeRemoteSelections.of(connectionIdsToRemove)); + } + if (effects.length > 0) { + this.view.dispatch({ effects }); + } + } + ); + } + + update(update: ViewUpdate) { + if (!update.selectionSet && !update.docChanged) return; + if ( + update.transactions.some((tr) => tr.annotation(Transaction.remote)) + ) { + return; + } + const selection = this.view.state.selection.main; + const encodedAnchor = text[kInternal].encodeIndex(selection.anchor); + const encodedHead = + selection.head === selection.anchor + ? encodedAnchor + : text[kInternal].encodeIndex(selection.head); + room.updatePresence({ + selection: { + anchor: encodedAnchor, + head: encodedHead, + version: text.version, + }, + }); + } + + destroy() { + this.unsubscribeFromPresenceUpdates(); + this.unsubscribeFromStorageUpdates(); + room.updatePresence({ selection: null }); + } + } + ), + ]; +} diff --git a/packages/liveblocks-codemirror/src/sync-plugin.ts b/packages/liveblocks-codemirror/src/sync-plugin.ts new file mode 100644 index 00000000000..80e7e182077 --- /dev/null +++ b/packages/liveblocks-codemirror/src/sync-plugin.ts @@ -0,0 +1,323 @@ +import { + ChangeSet, + EditorSelection, + type Extension, + Transaction, +} from "@codemirror/state"; +import type { EditorView, ViewUpdate } from "@codemirror/view"; +import { keymap, ViewPlugin } from "@codemirror/view"; +import type { LiveText, Room } from "@liveblocks/client"; +import { kInternal } from "@liveblocks/core"; + +import { clamp } from "./utils"; + +export function createLiveblocksSyncPlugin( + room: Room, + text: LiveText +): Extension { + return [ + ViewPlugin.fromClass( + class { + private selectionByHistoryId = new Map< + number, + { + before: { anchor: number; head: number; version: number }; + after: { anchor: number; head: number; version: number }; + } + >(); + private unsubscribeFromStorageUpdates: () => void; + private unsubscribeFromPrivateHistory: () => void; + private groupingTimer: ReturnType | null = null; + private prevLocalChanges: ChangeSet | null = null; + private prevTime: number | null = null; + private pendingSelectionBefore: { + anchor: number; + head: number; + version: number; + } | null = null; + + constructor(private view: EditorView) { + this.unsubscribeFromStorageUpdates = room.subscribe( + text, + (updates) => { + for (const update of updates) { + if (update.type !== "LiveText" || update.node !== text) { + continue; + } + + const source = update.source; + if (source.origin === "local" && source.via === "edit") { + continue; + } + + let changes = ChangeSet.empty(view.state.doc.length); + let currentLength = view.state.doc.length; + + for (const change of update.updates) { + let step: ChangeSet | null = null; + if (change.type === "insert") { + step = ChangeSet.of( + [{ from: change.index, insert: change.text }], + currentLength + ); + } else if (change.type === "delete") { + step = ChangeSet.of( + [ + { + from: change.index, + to: change.index + change.length, + }, + ], + currentLength + ); + } else if (change.type === "format") { + continue; + } + + if (step === null) continue; + changes = changes.compose(step); + currentLength = changes.newLength; + } + + if (changes.empty) continue; + + let selection: EditorSelection | undefined; + if ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ) { + const id = + source.via === "undo" + ? room[kInternal].redoStack.at(-1)?.id + : room[kInternal].undoStack.at(-1)?.id; + const meta = + id === undefined + ? undefined + : this.selectionByHistoryId.get(id); + if (meta !== undefined) { + if (source.via === "undo") { + if (meta.before.anchor !== meta.before.head) { + selection = EditorSelection.single( + clamp(meta.before.anchor, { + min: 0, + max: changes.newLength, + }), + clamp(meta.before.head, { + min: 0, + max: changes.newLength, + }) + ); + } else { + const anchor = text[kInternal].decodeIndex( + meta.before.anchor, + meta.before.version + ); + if (anchor !== null) { + selection = EditorSelection.single(anchor); + } else { + selection = EditorSelection.single( + clamp(meta.before.anchor, { + min: 0, + max: changes.newLength, + }) + ); + } + } + } else { + const anchor = text[kInternal].decodeIndex( + meta.after.anchor, + meta.after.version + ); + const head = text[kInternal].decodeIndex( + meta.after.head, + meta.after.version + ); + if (anchor !== null && head !== null) { + selection = EditorSelection.single(anchor, head); + } + } + } + } + + this.view.dispatch({ + changes, + annotations: [ + Transaction.addToHistory.of(false), + Transaction.remote.of(true), + ], + ...(selection !== undefined ? { selection } : {}), + }); + } + }, + { isDeep: true } + ); + + this.unsubscribeFromPrivateHistory = room[ + kInternal + ].history.subscribe((event) => { + if (event.action === "push") { + if (this.pendingSelectionBefore !== null) { + const afterMain = this.view.state.selection.main; + const afterAnchor = text[kInternal].encodeIndex( + afterMain.anchor + ); + this.selectionByHistoryId.set(event.id, { + before: this.pendingSelectionBefore, + after: { + anchor: afterAnchor, + head: + afterMain.head === afterMain.anchor + ? afterAnchor + : text[kInternal].encodeIndex(afterMain.head), + version: text.version, + }, + }); + this.pendingSelectionBefore = null; + this.prevLocalChanges = null; + this.prevTime = null; + } + } else if (event.action === "discard") { + for (const id of event.ids) this.selectionByHistoryId.delete(id); + } else if (event.action === "clear") { + this.selectionByHistoryId.clear(); + } + }); + } + + update(update: ViewUpdate) { + if ( + update.transactions.some((tr) => tr.annotation(Transaction.remote)) + ) { + return; + } + + if (!update.docChanged) { + if (update.selectionSet && this.prevLocalChanges !== null) { + if (this.groupingTimer !== null) { + clearTimeout(this.groupingTimer); + this.groupingTimer = null; + } + room.history.resume(); + } + return; + } + + let userEvent: string | undefined; + let time = Date.now(); + for (const tr of update.transactions) { + const e = tr.annotation(Transaction.userEvent); + if (e !== undefined) userEvent = e; + const t = tr.annotation(Transaction.time); + if (t !== undefined) time = t; + } + const isCompose = userEvent === "input.type.compose"; + + if (this.prevLocalChanges !== null && !isCompose) { + const joinable = + userEvent === undefined || JOINABLE_USER_EVENT.test(userEvent); + if ( + !joinable || + (this.prevTime !== null && time - this.prevTime >= 500) || + !isAdjacent(this.prevLocalChanges, update.changes) + ) { + if (this.groupingTimer !== null) { + clearTimeout(this.groupingTimer); + this.groupingTimer = null; + } + room.history.resume(); + } + } + + if (this.pendingSelectionBefore === null) { + const beforeMain = update.startState.selection.main; + const beforeAnchor = text[kInternal].encodeIndex(beforeMain.anchor); + this.pendingSelectionBefore = { + anchor: beforeAnchor, + head: + beforeMain.head === beforeMain.anchor + ? beforeAnchor + : text[kInternal].encodeIndex(beforeMain.head), + version: text.version, + }; + } + + room.history.pause(); + room.batch(() => { + let offset = 0; + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + const deleteLength = toA - fromA; + const insertText = inserted.toString(); + const index = fromA + offset; + + text.replace(index, deleteLength, insertText); + + offset += insertText.length - deleteLength; + }); + }); + + this.prevLocalChanges = this.prevLocalChanges + ? this.prevLocalChanges.compose(update.changes) + : update.changes; + this.prevTime = time; + + if (this.groupingTimer !== null) { + clearTimeout(this.groupingTimer); + } + this.groupingTimer = setTimeout(() => { + room.history.resume(); + this.groupingTimer = null; + }, 500); + } + + destroy() { + if (this.groupingTimer !== null) { + clearTimeout(this.groupingTimer); + this.groupingTimer = null; + } + room.history.resume(); + this.unsubscribeFromStorageUpdates(); + this.unsubscribeFromPrivateHistory(); + } + } + ), + keymap.of([ + { + key: "Mod-z", + run: () => { + room.history.resume(); + if (!room.history.canUndo()) return false; + room.history.undo(); + return true; + }, + preventDefault: true, + }, + { + key: "Mod-y", + mac: "Shift-Mod-z", + run: () => { + room.history.resume(); + if (!room.history.canRedo()) return false; + room.history.redo(); + return true; + }, + preventDefault: true, + }, + ]), + ]; +} + +const JOINABLE_USER_EVENT = /^(input\.type|delete)($|\.)/; + +export function isAdjacent(prev: ChangeSet, next: ChangeSet): boolean { + const ranges: number[] = []; + let adjacent = false; + prev.iterChangedRanges((_f, _t, f, t) => ranges.push(f, t)); + next.iterChangedRanges((f, t) => { + for (let i = 0; i < ranges.length; ) { + const from = ranges[i++]; + const to = ranges[i++]; + if (t >= from && f <= to) adjacent = true; + } + }); + return adjacent; +} diff --git a/packages/liveblocks-codemirror/src/utils.ts b/packages/liveblocks-codemirror/src/utils.ts new file mode 100644 index 00000000000..dbb886657e6 --- /dev/null +++ b/packages/liveblocks-codemirror/src/utils.ts @@ -0,0 +1,6 @@ +export function clamp( + value: number, + { min, max }: { min: number; max: number } +): number { + return Math.max(min, Math.min(value, max)); +} diff --git a/packages/liveblocks-codemirror/src/version.ts b/packages/liveblocks-codemirror/src/version.ts new file mode 100644 index 00000000000..41da4666511 --- /dev/null +++ b/packages/liveblocks-codemirror/src/version.ts @@ -0,0 +1,6 @@ +declare const __VERSION__: string; +declare const TSUP_FORMAT: string; + +export const PKG_NAME = "@liveblocks/codemirror"; +export const PKG_VERSION = typeof __VERSION__ === "string" && __VERSION__; +export const PKG_FORMAT = typeof TSUP_FORMAT === "string" && TSUP_FORMAT; diff --git a/packages/liveblocks-codemirror/tsconfig.json b/packages/liveblocks-codemirror/tsconfig.json new file mode 100644 index 00000000000..5fa21b678eb --- /dev/null +++ b/packages/liveblocks-codemirror/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../shared/tsconfig.common.json", + + "compilerOptions": { + "lib": ["dom", "es2022"], + "noUncheckedIndexedAccess": false + }, + "include": ["src"] +} diff --git a/packages/liveblocks-codemirror/tsup.config.ts b/packages/liveblocks-codemirror/tsup.config.ts new file mode 100644 index 00000000000..1309d0178df --- /dev/null +++ b/packages/liveblocks-codemirror/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + dts: true, + splitting: true, + clean: true, + format: ["esm", "cjs"], + sourcemap: true, + + esbuildOptions(options, _context) { + // Replace __VERSION__ globals with concrete version + const pkg = require("./package.json"); + options.define.__VERSION__ = JSON.stringify(pkg.version); + }, +}); diff --git a/packages/liveblocks-codemirror/vitest.config.ts b/packages/liveblocks-codemirror/vitest.config.ts new file mode 100644 index 00000000000..25ed75d3a21 --- /dev/null +++ b/packages/liveblocks-codemirror/vitest.config.ts @@ -0,0 +1,8 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "happy-dom", + include: ["src/**/*.test.[jt]s?(x)"], + }, +}); diff --git a/packages/liveblocks-core/.dependency-cruiser.js b/packages/liveblocks-core/.dependency-cruiser.js index 7688ad91268..6fb05c27098 100644 --- a/packages/liveblocks-core/.dependency-cruiser.js +++ b/packages/liveblocks-core/.dependency-cruiser.js @@ -145,6 +145,15 @@ export default { to: { pathNot: "^src/(globals|lib)/" }, }, + { + name: "illegal-import-from-internal", + comment: + "internal.ts contains shared foundational constants and must not depend on Liveblocks-specific modules.", + severity: "error", + from: { path: "^src/internal\\.ts$" }, + to: { pathNot: "^src/(globals|lib)/" }, + }, + // "Swimlane 1" - protocol/ { name: "illegal-import-from-protocol", @@ -179,10 +188,13 @@ export default { { name: "illegal-import-from-crdts", comment: - "All modules in crdts/ must have no other dependencies apart from refs/, types/, protocol/ or lib/.", + "All modules in crdts/ must have no other dependencies apart from internal.ts, refs/, types/, protocol/ or lib/.", severity: "error", from: { path: "^src/crdts/" }, - to: { pathNot: "^src/(globals|crdts|refs|types|protocol|lib)/" }, + to: { + pathNot: + "(^src/(globals|crdts|refs|types|protocol|lib)/|^src/internal\\.ts$)", + }, }, ], diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index ecefa725184..1a0d797985d 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.23.1", + "version": "3.24.0", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", @@ -37,11 +37,12 @@ "format": "(eslint --fix src/ e2e/ || true) && prettier --write src/ e2e/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", - "test": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", - "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", + "typecheck": "tsc --noEmit", + "test": "pnpm dlx liveblocks@pre dev -P -c 'vitest run --coverage'", + "test:ci": "pnpm dlx liveblocks@pre dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest", - "test:e2e": "pnpm dlx liveblocks dev -P -c 'vitest run --config=./vitest.config.e2e.ts'", + "test:e2e": "pnpm dlx liveblocks@pre dev -P -c 'vitest run --config=./vitest.config.e2e.ts'", "test:deps": "depcruise src --exclude __tests__", "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg", "showdeps:high-level": "depcruise src --include-only '^src' --exclude='(^src/index.ts|shallow.ts|__tests__)' --collapse='^src/(refs|lib|compat|types|crdts|protocol)' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" diff --git a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts index 626b54801fa..8f2a99a90e4 100644 --- a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts +++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts @@ -307,7 +307,7 @@ export async function prepareStorageTest< ref.wss.last.send( serverMessage({ type: ServerMsgCode.UPDATE_STORAGE, - ops: message.ops, + ops: message.ops.map(({ opId: _, ...op }) => op), }) ); subject.wss.last.send( @@ -376,9 +376,7 @@ export async function prepareStorageTest< // this is what the last undo item looked like before we undo const before = deepCloneWithoutOpId( - subject.room[kInternal].undoStack[ - subject.room[kInternal].undoStack.length - 1 - ] + subject.room[kInternal].undoStack.at(-1)!.frames ); // this will undo the whole stack @@ -395,9 +393,7 @@ export async function prepareStorageTest< // this is what the last undo item looks like after redoing everything const after = deepCloneWithoutOpId( - subject.room[kInternal].undoStack[ - subject.room[kInternal].undoStack.length - 1 - ] + subject.room[kInternal].undoStack.at(-1)!.frames ); // It should be identical before/after @@ -505,7 +501,7 @@ export async function prepareStorageUpdateTest< ref.wss.last.send( serverMessage({ type: ServerMsgCode.UPDATE_STORAGE, - ops: message.ops, + ops: message.ops.map(({ opId: _, ...op }) => op), }) ); subject.wss.last.send( diff --git a/packages/liveblocks-core/src/__tests__/_devserver.ts b/packages/liveblocks-core/src/__tests__/_devserver.ts index 80c8c86460c..ba84fe3edda 100644 --- a/packages/liveblocks-core/src/__tests__/_devserver.ts +++ b/packages/liveblocks-core/src/__tests__/_devserver.ts @@ -207,9 +207,7 @@ export async function prepareStorageTest( async function assertUndoRedo() { const before = deepCloneWithoutOpId( - clientA.room[kInternal].undoStack[ - clientA.room[kInternal].undoStack.length - 1 - ] + clientA.room[kInternal].undoStack.at(-1)!.frames ); // Undo the whole stack @@ -225,9 +223,7 @@ export async function prepareStorageTest( } const after = deepCloneWithoutOpId( - clientA.room[kInternal].undoStack[ - clientA.room[kInternal].undoStack.length - 1 - ] + clientA.room[kInternal].undoStack.at(-1)!.frames ); // It should be identical before/after diff --git a/packages/liveblocks-core/src/__tests__/_updatesUtils.ts b/packages/liveblocks-core/src/__tests__/_updatesUtils.ts index fdc05b13a1d..887d68aef07 100644 --- a/packages/liveblocks-core/src/__tests__/_updatesUtils.ts +++ b/packages/liveblocks-core/src/__tests__/_updatesUtils.ts @@ -1,6 +1,7 @@ import type { Json, LiveMap, Lson, LsonObject, StorageUpdate } from ".."; import type { LiveListUpdates } from "../crdts/LiveList"; import type { LiveObjectUpdateDelta } from "../crdts/LiveObject"; +import type { LiveTextUpdates } from "../crdts/LiveText"; import type { ToJson } from "../crdts/Lson"; import type { UpdateDelta } from "../crdts/UpdateDelta"; import { lsonToJson } from "../immutable"; @@ -9,7 +10,8 @@ import { assertNever } from "../lib/assert"; export type JsonStorageUpdate = | JsonLiveListUpdate | JsonLiveObjectUpdate - | JsonLiveMapUpdate; + | JsonLiveMapUpdate + | JsonLiveTextUpdate; export type JsonLiveListUpdate = { type: "LiveList"; @@ -52,6 +54,13 @@ export type JsonLiveMapUpdate = { updates: { [key: string]: UpdateDelta }; }; +export type JsonLiveTextUpdate = { + type: "LiveText"; + node: ReturnType; + version: number; + updates: LiveTextUpdates["updates"]; +}; + export function liveListUpdateToJson( update: LiveListUpdates ): JsonLiveListUpdate { @@ -117,6 +126,15 @@ export function serializeUpdateToJson( }; } + if (update.type === "LiveText") { + return { + type: update.type, + node: update.node.toJSON(), + version: update.version, + updates: update.updates, + }; + } + return assertNever(update, "Unsupported LiveStructure type"); } diff --git a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts index 24c02cb6958..d3622d02963 100644 --- a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts @@ -8,9 +8,12 @@ import { describe, expect, onTestFinished, test, vi } from "vitest"; import { LiveList } from "../crdts/LiveList"; +import type { LiveMap } from "../crdts/LiveMap"; import { LiveObject } from "../crdts/LiveObject"; +import type { LiveText } from "../crdts/LiveText"; +import type { StorageUpdate, UpdateSource } from "../crdts/StorageUpdates"; import { nn } from "../lib/assert"; -import { prepareIsolatedStorageTest } from "./_devserver"; +import { prepareIsolatedStorageTest, prepareStorageTest } from "./_devserver"; import type { JsonStorageUpdate } from "./_updatesUtils"; import { listUpdate, @@ -640,4 +643,235 @@ describe("room (dev server)", () => { expect(callback).not.toHaveBeenCalled(); }); + + describe("storage update source", () => { + function readSources(updates: StorageUpdate[]): UpdateSource[] { + return updates.map((update) => update.source); + } + + test("local LiveObject mutations are tagged local", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.set("a", 1); + + expect(sources).toEqual([{ origin: "local", via: "edit" }]); + }); + + test("local LiveText mutations are tagged local on the envelope only", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + text: LiveText; + }>({ + liveblocksType: "LiveObject", + data: { + text: { liveblocksType: "LiveText", data: [["hello"]] }, + }, + }); + + const updates: Array<{ + source: UpdateSource; + changeHasSource: boolean; + }> = []; + onTestFinished( + room.events.storageBatch.subscribe((batch) => { + for (const update of batch) { + if (update.type !== "LiveText") { + continue; + } + updates.push({ + source: update.source, + changeHasSource: update.updates.some( + (change) => "source" in change + ), + }); + } + }) + ); + + root.get("text").insert(5, " world"); + + expect(updates).toEqual([ + { + source: { origin: "local", via: "edit" }, + changeHasSource: false, + }, + ]); + }); + + test("remote mutations are tagged remote on the receiving client", async () => { + const { storageA, roomB } = await prepareStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + roomB.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + storageA.root.set("a", 1); + + await vi.waitFor(() => { + expect(sources.length).toBeGreaterThan(0); + }); + + expect(sources).toEqual([{ origin: "remote" }]); + }); + + test("remote LiveText mutations are tagged remote on the receiving client", async () => { + const { storageA, roomB } = await prepareStorageTest<{ + text: LiveText; + }>({ + liveblocksType: "LiveObject", + data: { + text: { liveblocksType: "LiveText", data: [["hello"]] }, + }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + roomB.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + storageA.root.get("text").insert(5, " world"); + + await vi.waitFor(() => { + expect(sources.length).toBeGreaterThan(0); + }); + + expect(sources).toEqual([{ origin: "remote" }]); + }); + + test("local LiveList and LiveMap mutations are tagged local", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + items: LiveList; + map: LiveMap; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["a"] }, + map: { liveblocksType: "LiveMap", data: { k: "old" } }, + }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.get("items").insert("b", 1); + root.get("map").set("k", "new"); + + expect(sources).toEqual([ + { origin: "local", via: "edit" }, + { origin: "local", via: "edit" }, + ]); + }); + + test("remote LiveList and LiveMap mutations are tagged remote on the receiving client", async () => { + const { storageA, roomB } = await prepareStorageTest<{ + items: LiveList; + map: LiveMap; + }>({ + liveblocksType: "LiveObject", + data: { + items: { liveblocksType: "LiveList", data: ["a"] }, + map: { liveblocksType: "LiveMap", data: { k: "old" } }, + }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + roomB.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + storageA.root.get("items").insert("b", 1); + storageA.root.get("map").set("k", "new"); + + await vi.waitFor(() => { + expect(sources.length).toBe(2); + }); + + expect(sources).toEqual([{ origin: "remote" }, { origin: "remote" }]); + }); + + test("undo and redo produce undo/redo-tagged storage updates", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.set("a", 1); + expect(sources).toEqual([{ origin: "local", via: "edit" }]); + + sources.length = 0; + room.history.undo(); + + expect(sources).toEqual([{ origin: "local", via: "undo" }]); + + sources.length = 0; + room.history.redo(); + + expect(sources).toEqual([{ origin: "local", via: "redo" }]); + }); + + test("the internal optimistic flag never reaches subscribers", async () => { + const { storageA, roomA, storageB, roomB } = await prepareStorageTest<{ + a: number; + }>({ + liveblocksType: "LiveObject", + data: { a: 0 }, + }); + + const sources: UpdateSource[] = []; + onTestFinished( + roomA.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + onTestFinished( + roomB.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + // A local mutation is optimistic internally; its ack is not; an undo + // replays an unacknowledged op; and B sees all of it as remote. + storageA.root.set("a", 1); + roomA.history.undo(); + storageB.root.set("a", 9); + + await vi.waitFor(() => { + expect(sources.length).toBeGreaterThan(3); + }); + + for (const source of sources) { + expect(source).not.toHaveProperty("optimistic"); + } + }); + }); }); diff --git a/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts index bac9b6a9374..1da765d7a52 100644 --- a/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts @@ -23,7 +23,9 @@ import { DEFAULT_BASE_URL } from "../constants"; import { LiveList } from "../crdts/LiveList"; import { LiveMap } from "../crdts/LiveMap"; import { LiveObject } from "../crdts/LiveObject"; +import type { LiveText } from "../crdts/LiveText"; import type { LsonObject } from "../crdts/Lson"; +import type { StorageUpdate, UpdateSource } from "../crdts/StorageUpdates"; import { kInternal } from "../internal"; import { makeEventSource } from "../lib/EventSource"; import * as console from "../lib/fancy-console"; @@ -36,7 +38,7 @@ import { OpCode } from "../protocol/Op"; import { ServerMsgCode } from "../protocol/ServerMsg"; import type { StorageNode } from "../protocol/StorageNode"; import { CrdtType, nodeStreamToCompactNodes } from "../protocol/StorageNode"; -import type { RoomConfig, RoomDelegates } from "../room"; +import type { PrivateHistoryEvent, RoomConfig, RoomDelegates } from "../room"; import { createRoom } from "../room"; import { WebsocketCloseCodes } from "../types/IWebSocket"; import type { LiveblocksError } from "../types/LiveblocksError"; @@ -53,6 +55,7 @@ import { } from "./_MockWebSocketServer.behaviors"; import { createSerializedList, + createSerializedMap, createSerializedObject, createSerializedRegister, createSerializedRoot, @@ -2497,6 +2500,70 @@ describe("room", () => { }); }); + describe("version history restore", () => { + test("restores an existing LiveText through a forward UPDATE_TEXT op", async () => { + const { root, room, wss } = await prepareIsolatedStorageTest<{ + text: LiveText; + }>( + [ + createSerializedRoot(), + [ + "text", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Current"]], + version: 7, + }, + ], + ], + 1 + ); + + room[kInternal].reconcileStorageWithNodes([ + createSerializedRoot(), + [ + "text", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Historic", { bold: true }]], + version: 2, + }, + ], + ]); + + expect(root.get("text").toJSON()).toEqual([["Historic", { bold: true }]]); + expect(wss.receivedMessages.at(-1)).toEqual([ + { + type: ClientMsgCode.UPDATE_STORAGE, + ops: [ + { + type: OpCode.UPDATE_TEXT, + id: "text", + opId: "1:0", + baseVersion: 7, + ops: [ + { type: "delete", index: 0, length: 7 }, + { + type: "insert", + index: 0, + text: "Historic", + attributes: { bold: true }, + }, + ], + }, + ], + }, + ]); + + room.history.undo(); + expect(root.get("text").toString()).toBe("Current"); + }); + }); + describe("room load promises", () => { test("presence-ready promise", async () => { const { room } = createTestableRoom({ @@ -2759,4 +2826,319 @@ describe("room", () => { room.destroy(); }); }); + + describe("storage update source", () => { + function readSources(updates: StorageUpdate[]): UpdateSource[] { + return updates.map((update) => update.source); + } + + test("local mutations are tagged local", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 0 })], + 0, + { a: 0 } + ); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.set("a", 1); + + expect(sources).toEqual([{ origin: "local", via: "edit" }]); + }); + + test("remote ops without opId are tagged remote", async () => { + const { room, root, applyRemoteOperations } = + await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 0 })], + 0, + { a: 0 } + ); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_OBJECT, + id: "root", + data: { a: 2 }, + }, + ]); + + expect(sources).toEqual([{ origin: "remote" }]); + expect(root.get("a")).toBe(2); + }); + + test("peer client receives remote-tagged updates via prepareStorageTest", async () => { + const { refRoom, storage } = await prepareStorageTest<{ a: number }>( + [createSerializedRoot({ a: 0 })], + 0 + ); + + const sources: UpdateSource[] = []; + onTestFinished( + refRoom.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + storage.root.set("a", 1); + + expect(sources).toEqual([{ origin: "remote" }]); + }); + + test("undo and redo produce undo/redo-tagged storage updates", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 0 })], + 0, + { a: 0 } + ); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.set("a", 1); + expect(sources).toEqual([{ origin: "local", via: "edit" }]); + + sources.length = 0; + room.history.undo(); + + expect(sources).toEqual([{ origin: "local", via: "undo" }]); + + sources.length = 0; + room.history.redo(); + + expect(sources).toEqual([{ origin: "local", via: "redo" }]); + }); + + test("an acknowledgement keeps the via of the change it confirms", async () => { + const { room, root, applyRemoteOperations } = + await prepareIsolatedStorageTest<{ map: LiveMap }>( + [ + createSerializedRoot(), + createSerializedMap("0:1", "root", "map"), + createSerializedRegister("0:2", "0:1", "k", "old"), + ], + 1 + ); + + const sources: UpdateSource[] = []; + onTestFinished( + room.events.storageBatch.subscribe((updates) => { + sources.push(...readSources(updates)); + }) + ); + + root.get("map").set("k", "new"); + room.history.undo(); // Restores "old" under a fresh opId + + // Another client overwrites the same key before our undo is acked, which + // clears the pending-set bookkeeping for that key... + applyRemoteOperations([ + { + type: OpCode.CREATE_REGISTER, + id: "2:0", + parentId: "0:1", + parentKey: "k", + data: "remote", + }, + ]); + + // ...so when the server acks the undo (by echoing it back verbatim), it + // lands as a real change instead of a no-op + applyRemoteOperations([ + { + type: OpCode.CREATE_REGISTER, + id: "0:2", + parentId: "0:1", + parentKey: "k", + data: "old", + opId: "1:1", + }, + ]); + + expect(root.get("map").get("k")).toBe("old"); + expect(sources).toEqual([ + { origin: "local", via: "edit" }, + { origin: "local", via: "undo" }, + { origin: "remote" }, + { origin: "local", via: "undo" }, // The ack, not a fresh "edit" + ]); + }); + }); + + describe("private history events", () => { + test("undo and redo notify history before storage", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const order: string[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => { + if (event.action === "undo" || event.action === "redo") { + order.push(`history:${event.action}`); + } + }) + ); + onTestFinished( + room.events.storageBatch.subscribe(() => { + order.push("storage"); + }) + ); + + root.set("a", 2); + + order.length = 0; + room.history.undo(); + expect(order).toEqual(["history:undo", "storage"]); + + order.length = 0; + room.history.redo(); + expect(order).toEqual(["history:redo", "storage"]); + }); + + test("push, undo, and redo emit stable ids", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + root.set("a", 2); + expect(events).toEqual([{ action: "push", id: 0 }]); + + events.length = 0; + room.history.undo(); + expect(events).toEqual([{ action: "undo", id: 0 }]); + + events.length = 0; + room.history.redo(); + expect(events).toEqual([{ action: "redo", id: 0 }]); + }); + + test("new edit after undo discards redo ids", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + root.set("a", 2); + room.history.undo(); + + events.length = 0; + root.set("a", 3); + + expect(events).toEqual([ + { action: "push", id: 1 }, + { action: "discard", ids: [0] }, + ]); + }); + + test("clear emits a clear event", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + root.set("a", 2); + room.history.undo(); + + events.length = 0; + room.history.clear(); + + expect(events).toEqual([{ action: "clear" }]); + }); + + test("batch coalesces to one push", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + room.batch(() => { + root.set("a", 2); + root.set("a", 3); + }); + + expect(events).toEqual([{ action: "push", id: 0 }]); + }); + + test("history.disable suppresses private history events", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 1 })], + 0, + { a: 1 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + room.history.disable(() => { + root.set("a", 2); + }); + + expect(events).toEqual([]); + }); + + test("evicting the oldest undo item emits discard", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ a: number }>( + [createSerializedRoot({ a: 0 })], + 0, + { a: 0 } + ); + + const events: PrivateHistoryEvent[] = []; + onTestFinished( + room[kInternal].history.subscribe((event) => events.push(event)) + ); + + for (let i = 1; i <= 51; i++) { + root.set("a", i); + } + + expect(events.at(-2)).toEqual({ action: "discard", ids: [0] }); + expect(events.at(-1)).toEqual({ action: "push", id: 50 }); + }); + }); }); diff --git a/packages/liveblocks-core/src/crdts/AbstractCrdt.ts b/packages/liveblocks-core/src/crdts/AbstractCrdt.ts index 43a79a8e611..de4c532f94b 100644 --- a/packages/liveblocks-core/src/crdts/AbstractCrdt.ts +++ b/packages/liveblocks-core/src/crdts/AbstractCrdt.ts @@ -1,4 +1,6 @@ +import { kInternal } from "../internal"; import { assertNever } from "../lib/assert"; +import * as console from "../lib/fancy-console"; import type { ReadonlyJson } from "../lib/Json"; import type { Pos } from "../lib/position"; import { asPos } from "../lib/position"; @@ -12,14 +14,43 @@ import { OpCode } from "../protocol/Op"; import type { SerializedCrdt } from "../protocol/StorageNode"; import type * as DevTools from "../types/DevToolsTreeNode"; import type { LiveNode, Lson } from "./Lson"; -import type { StorageUpdate } from "./StorageUpdates"; +import type { OpSource, StorageUpdate, UpdateSource } from "./StorageUpdates"; +import { toUpdateSource } from "./StorageUpdates"; import type { ReadonlyUnacknowledgedOps } from "./UnacknowledgedOps"; import { UnacknowledgedOps } from "./UnacknowledgedOps"; +const warnedOrphanedNodes = new WeakSet(); + +const ORPHANED_NODE_WARNING = + "Cannot sync changes made to this Live structure because it is no longer part of Storage. Retrieve the current value from its parent before mutating it."; + export type ApplyResult = | { reverse: Op[]; modified: StorageUpdate } | { modified: false }; +export type DispatchOptions = { + /** + * Whether this dispatch should clear the redo stack. Defaults to true when + * any forward ops are included (a fresh local mutation), false otherwise. + * LiveText uses this to dispatch queued ops after an acknowledgement + * (which should not clear redo), and to register fresh local edits that + * don't carry wire ops yet (which should). + */ + clearRedoStack?: boolean; +}; + +/** + * Private methods on any Liveblocks CRDT node. As a user of Liveblocks, NEVER + * USE ANY OF THESE DIRECTLY, because bad things will probably happen if you do. + */ +export type PrivateLiveNodeApi = { + /** + * Returns the CRDT node id once attached to the room pool. Detached nodes + * that have not entered storage yet return `undefined`. + */ + getId(): string | undefined; +}; + /** * The managed pool is a namespace registry (i.e. a context) that "owns" all * the individual live nodes, ensuring each one has a unique ID, and holding on @@ -43,7 +74,8 @@ export interface ManagedPool { dispatch: ( ops: ClientWireOp[], reverseOps: Op[], - storageUpdates: Map + storageUpdates: Map, + options?: DispatchOptions ) => void; /** @@ -76,7 +108,8 @@ export type CreateManagedPoolOptions = { onDispatch?: ( ops: ClientWireOp[], reverse: Op[], - storageUpdates: Map + storageUpdates: Map, + options?: DispatchOptions ) => void; /** @@ -126,9 +159,10 @@ export function createManagedPool( dispatch( ops: ClientWireOp[], reverse: Op[], - storageUpdates: Map + storageUpdates: Map, + options?: DispatchOptions ) { - onDispatch?.(ops, reverse, storageUpdates); + onDispatch?.(ops, reverse, storageUpdates, options); }, assertStorageIsWritable: () => { @@ -143,36 +177,6 @@ export function createManagedPool( }; } -/** - * When applying an op to a CRDT, we need to know where it came from to apply - * it correctly. - */ -export enum OpSource { - /** - * Optimistic update applied locally (from an undo, redo, or reconnect). Not - * yet acknowledged by the server. Will be sent to server and needs to be - * tracked for conflict resolution. - */ - LOCAL, - - /** - * Op received from server, originated from another client. Apply it, unless - * there's a pending local op for the same key (local ops take precedence - * until acknowledged). - * - * Note that a "fix Op" sent by the server in response to a local mutation - * that caused a conflict will also be classified as a THEIRS-like mutation. - * (As if another client resolved the conflict.) - */ - THEIRS, - - /** - * Op received from server, originated from THIS client. Server echoed it - * back to confirm. - */ - OURS, -} - // TODO Temporary helper to help convert from AbstractCrdt -> LiveNode, only // needed for within this module. The reason is that AbstractCrdt is an // _abstract_ type, and in our LiveNode union we exhaustively include all @@ -245,11 +249,22 @@ type ParentInfo = export abstract class AbstractCrdt { // ^^^^^^^^^^^^ TODO: Make this an interface + declare readonly [kInternal]: PrivateLiveNodeApi; + #pool?: ManagedPool; #id?: string; #parent: ParentInfo = NoParent; + constructor() { + Object.defineProperty(this, kInternal, { + value: { + getId: (): string | undefined => this.#id, + }, + enumerable: false, + }); + } + /** @internal */ _getParentKeyOrThrow(): string { switch (this.parent.type) { @@ -299,6 +314,18 @@ export abstract class AbstractCrdt { return this.#parent; } + /** @internal */ + protected _warnIfOrphaned(): void { + const node = crdtAsLiveNode(this); + if (this.parent.type === "Orphaned" && !warnedOrphanedNodes.has(node)) { + warnedOrphanedNodes.add(node); + console.warn(ORPHANED_NODE_WARNING, { + type: node.constructor.name, + formerParentKey: this.parent.oldKey, + }); + } + } + /** @internal */ get _parentKey(): string | null { switch (this.parent.type) { @@ -317,11 +344,14 @@ export abstract class AbstractCrdt { } /** @internal */ - _apply(op: Op, _isLocal: boolean): ApplyResult { + _apply(op: Op, source: OpSource): ApplyResult { switch (op.type) { case OpCode.DELETE_CRDT: { if (this.parent.type === "HasParent") { - return this.parent.node._detachChild(crdtAsLiveNode(this)); + return this.parent.node._detachChild( + crdtAsLiveNode(this), + toUpdateSource(source) + ); } return { modified: false }; @@ -399,7 +429,7 @@ export abstract class AbstractCrdt { } /** @internal */ - abstract _detachChild(crdt: LiveNode): ApplyResult; + abstract _detachChild(crdt: LiveNode, source: UpdateSource): ApplyResult; /** * Serializes this CRDT and all its children into a list of creation ops diff --git a/packages/liveblocks-core/src/crdts/LiveFile.ts b/packages/liveblocks-core/src/crdts/LiveFile.ts index 713c2acc638..db4b5e25d43 100644 --- a/packages/liveblocks-core/src/crdts/LiveFile.ts +++ b/packages/liveblocks-core/src/crdts/LiveFile.ts @@ -14,6 +14,7 @@ import type * as DevTools from "../types/DevToolsTreeNode"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import type { ApplyResult, ManagedPool } from "./AbstractCrdt"; import { AbstractCrdt } from "./AbstractCrdt"; +import type { OpSource } from "./StorageUpdates"; export type { LiveFileData } from "../protocol/StorageNode"; @@ -108,8 +109,8 @@ export class LiveFile extends AbstractCrdt { } /** @internal */ - _apply(op: Op, isLocal: boolean): ApplyResult { - return super._apply(op, isLocal); + _apply(op: Op, source: OpSource): ApplyResult { + return super._apply(op, source); } /** @internal */ diff --git a/packages/liveblocks-core/src/crdts/LiveList.ts b/packages/liveblocks-core/src/crdts/LiveList.ts index 064f25be88c..745c02527fd 100644 --- a/packages/liveblocks-core/src/crdts/LiveList.ts +++ b/packages/liveblocks-core/src/crdts/LiveList.ts @@ -11,7 +11,7 @@ import { CrdtType } from "../protocol/StorageNode"; import type * as DevTools from "../types/DevToolsTreeNode"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import type { ApplyResult, ManagedPool } from "./AbstractCrdt"; -import { AbstractCrdt, OpSource } from "./AbstractCrdt"; +import { AbstractCrdt } from "./AbstractCrdt"; import { creationOpToLiveNode, deserialize, @@ -20,6 +20,8 @@ import { } from "./liveblocks-helpers"; import { LiveRegister } from "./LiveRegister"; import type { LiveNode, Lson, ToJson } from "./Lson"; +import type { OpSource, UpdateSource } from "./StorageUpdates"; +import { LOCAL_EDIT, REMOTE, toUpdateSource } from "./StorageUpdates"; export type LiveListUpdateDelta = | { type: "insert"; index: number; item: Lson } @@ -35,6 +37,7 @@ export type LiveListUpdates = { type: "LiveList"; node: LiveList; updates: LiveListUpdateDelta[]; + source: UpdateSource; }; function childNodeLt(a: LiveNode, b: LiveNode): boolean { @@ -212,7 +215,7 @@ export class LiveList extends AbstractCrdt { } } - #applySetRemote(op: CreateOp): ApplyResult { + #applyRemoteSet(op: CreateOp): ApplyResult { if (this._pool === undefined) { throw new Error("Can't attach child if managed pool is not present"); } @@ -240,9 +243,11 @@ export class LiveList extends AbstractCrdt { this.#items.add(child); return { - modified: makeUpdate(this, [ - setDelta(indexOfItemWithSamePosition, child), - ]), + modified: makeUpdate( + this, + [setDelta(indexOfItemWithSamePosition, child)], + REMOTE + ), reverse: [], }; } else { @@ -263,7 +268,8 @@ export class LiveList extends AbstractCrdt { // Even if we implicitly delete the item at the set position // We still need to delete the item that was orginaly deleted by the set const deleteDelta = this.#detachItemAssociatedToSetOperation( - op.deletedId + op.deletedId, + REMOTE ); if (deleteDelta) { @@ -271,7 +277,7 @@ export class LiveList extends AbstractCrdt { } return { - modified: makeUpdate(this, delta), + modified: makeUpdate(this, delta, REMOTE), reverse: [], }; } @@ -279,7 +285,8 @@ export class LiveList extends AbstractCrdt { // Item at position to be replaced doesn't exist const updates: LiveListUpdateDelta[] = []; const deleteDelta = this.#detachItemAssociatedToSetOperation( - op.deletedId + op.deletedId, + REMOTE ); if (deleteDelta) { updates.push(deleteDelta); @@ -291,12 +298,12 @@ export class LiveList extends AbstractCrdt { return { reverse: [], - modified: makeUpdate(this, updates), + modified: makeUpdate(this, updates, REMOTE), }; } } - #applySetAck(op: CreateOp): ApplyResult { + #applySetAck(op: CreateOp, source: UpdateSource): ApplyResult { if (this._pool === undefined) { throw new Error("Can't attach child if managed pool is not present"); } @@ -304,7 +311,10 @@ export class LiveList extends AbstractCrdt { const delta: LiveListUpdateDelta[] = []; // Deleted item can be re-inserted by remote undo/redo - const deletedDelta = this.#detachItemAssociatedToSetOperation(op.deletedId); + const deletedDelta = this.#detachItemAssociatedToSetOperation( + op.deletedId, + source + ); if (deletedDelta) { delta.push(deletedDelta); } @@ -321,7 +331,7 @@ export class LiveList extends AbstractCrdt { if (unacknowledgedOpId !== undefined && unacknowledgedOpId !== op.opId) { return delta.length === 0 ? { modified: false } - : { modified: makeUpdate(this, delta), reverse: [] }; + : { modified: makeUpdate(this, delta, source), reverse: [] }; } const indexOfItemWithSamePosition = this._indexOfPosition(op.parentKey); @@ -334,7 +344,7 @@ export class LiveList extends AbstractCrdt { if (existingItem._parentKey === op.parentKey) { // ... do nothing return { - modified: delta.length > 0 ? makeUpdate(this, delta) : false, + modified: delta.length > 0 ? makeUpdate(this, delta, source) : false, reverse: [], }; } @@ -356,7 +366,7 @@ export class LiveList extends AbstractCrdt { } return { - modified: delta.length > 0 ? makeUpdate(this, delta) : false, + modified: delta.length > 0 ? makeUpdate(this, delta, source) : false, reverse: [], }; } else { @@ -370,13 +380,17 @@ export class LiveList extends AbstractCrdt { const recreatedItemIndex = this.#insert(orphan); return { - modified: makeUpdate(this, [ - // If there is an item at this position, update is a set, else it's an insert - indexOfItemWithSamePosition === -1 - ? insertDelta(recreatedItemIndex, orphan) - : setDelta(recreatedItemIndex, orphan), - ...delta, - ]), + modified: makeUpdate( + this, + [ + // If there is an item at this position, update is a set, else it's an insert + indexOfItemWithSamePosition === -1 + ? insertDelta(recreatedItemIndex, orphan) + : setDelta(recreatedItemIndex, orphan), + ...delta, + ], + source + ), reverse: [], }; } else { @@ -393,13 +407,17 @@ export class LiveList extends AbstractCrdt { ); return { - modified: makeUpdate(this, [ - // If there is an item at this position, update is a set, else it's an insert - indexOfItemWithSamePosition === -1 - ? insertDelta(newIndex, newItem) - : setDelta(newIndex, newItem), - ...delta, - ]), + modified: makeUpdate( + this, + [ + // If there is an item at this position, update is a set, else it's an insert + indexOfItemWithSamePosition === -1 + ? insertDelta(newIndex, newItem) + : setDelta(newIndex, newItem), + ...delta, + ], + source + ), reverse: [], }; } @@ -410,7 +428,8 @@ export class LiveList extends AbstractCrdt { * Returns the update delta of the deletion or null */ #detachItemAssociatedToSetOperation( - deletedId?: string + deletedId: string | undefined, + source: UpdateSource ): LiveListUpdateDelta | null { if (deletedId === undefined || this._pool === undefined) { return null; @@ -421,7 +440,7 @@ export class LiveList extends AbstractCrdt { return null; } - const result = this._detachChild(deletedItem); + const result = this._detachChild(deletedItem, source); if (result.modified === false) { return null; } @@ -459,10 +478,11 @@ export class LiveList extends AbstractCrdt { const bumpDeltas = this.#bumpUnackedPushesAbove(key); return { - modified: makeUpdate(this, [ - insertDelta(newIndex, newItem), - ...bumpDeltas, - ]), + modified: makeUpdate( + this, + [insertDelta(newIndex, newItem), ...bumpDeltas], + REMOTE + ), reverse: [], }; } @@ -554,7 +574,7 @@ export class LiveList extends AbstractCrdt { return deltas; } - #applyInsertAck(op: CreateOp): ApplyResult { + #applyInsertAck(op: CreateOp, source: UpdateSource): ApplyResult { const existingItem = this.#items.find((item) => item._id === op.id); const key = asPos(op.parentKey); @@ -583,9 +603,11 @@ export class LiveList extends AbstractCrdt { } return { - modified: makeUpdate(this, [ - moveDelta(oldPositionIndex, newIndex, existingItem), - ]), + modified: makeUpdate( + this, + [moveDelta(oldPositionIndex, newIndex, existingItem)], + source + ), reverse: [], }; } @@ -601,7 +623,7 @@ export class LiveList extends AbstractCrdt { const newIndex = this._indexOfPosition(key); return { - modified: makeUpdate(this, [insertDelta(newIndex, orphan)]), + modified: makeUpdate(this, [insertDelta(newIndex, orphan)], source), reverse: [], }; } else { @@ -612,14 +634,14 @@ export class LiveList extends AbstractCrdt { const { newItem, newIndex } = this.#createAttachItemAndSort(op, key); return { - modified: makeUpdate(this, [insertDelta(newIndex, newItem)]), + modified: makeUpdate(this, [insertDelta(newIndex, newItem)], source), reverse: [], }; } } } - #applyInsertUndoRedo(op: CreateOp): ApplyResult { + #applyLocalInsert(op: CreateOp, source: UpdateSource): ApplyResult { const { id, parentKey: key } = op; const child = creationOpToLiveNode(op); @@ -647,12 +669,12 @@ export class LiveList extends AbstractCrdt { const newIndex = this._indexOfPosition(newKey); return { - modified: makeUpdate(this, [insertDelta(newIndex, child)]), + modified: makeUpdate(this, [insertDelta(newIndex, child)], source), reverse: [{ type: OpCode.DELETE_CRDT, id }], }; } - #applySetUndoRedo(op: CreateOp): ApplyResult { + #applyLocalSet(op: CreateOp, source: UpdateSource): ApplyResult { const { id, parentKey: key } = op; const child = creationOpToLiveNode(op); @@ -684,33 +706,35 @@ export class LiveList extends AbstractCrdt { const delta = [setDelta(indexOfItemWithSameKey, child)]; const deletedDelta = this.#detachItemAssociatedToSetOperation( - op.deletedId + op.deletedId, + source ); if (deletedDelta) { delta.push(deletedDelta); } return { - modified: makeUpdate(this, delta), + modified: makeUpdate(this, delta, source), reverse, }; } else { this.#insert(child); // TODO: Use delta - this.#detachItemAssociatedToSetOperation(op.deletedId); + this.#detachItemAssociatedToSetOperation(op.deletedId, source); const newIndex = this._indexOfPosition(newKey); return { reverse: [{ type: OpCode.DELETE_CRDT, id }], - modified: makeUpdate(this, [insertDelta(newIndex, child)]), + modified: makeUpdate(this, [insertDelta(newIndex, child)], source), }; } } /** @internal */ - _attachChild(op: CreateOp, source: OpSource): ApplyResult { + _attachChild(op: CreateOp, opSource: OpSource): ApplyResult { + const source = toUpdateSource(opSource); if (this._pool === undefined) { throw new Error("Can't attach child if managed pool is not present"); } @@ -718,20 +742,20 @@ export class LiveList extends AbstractCrdt { let result: ApplyResult; if (op.intent === "set") { - if (source === OpSource.THEIRS) { - result = this.#applySetRemote(op); - } else if (source === OpSource.OURS) { - result = this.#applySetAck(op); + if (opSource.origin === "remote") { + result = this.#applyRemoteSet(op); + } else if (!opSource.optimistic) { + result = this.#applySetAck(op, source); } else { - result = this.#applySetUndoRedo(op); + result = this.#applyLocalSet(op, source); } } else { - if (source === OpSource.THEIRS) { + if (opSource.origin === "remote") { result = this.#applyRemoteInsert(op); - } else if (source === OpSource.OURS) { - result = this.#applyInsertAck(op); + } else if (!opSource.optimistic) { + result = this.#applyInsertAck(op, source); } else { - result = this.#applyInsertUndoRedo(op); + result = this.#applyLocalInsert(op, source); } } @@ -744,7 +768,8 @@ export class LiveList extends AbstractCrdt { /** @internal */ _detachChild( - child: LiveNode + child: LiveNode, + source: UpdateSource ): { reverse: Op[]; modified: LiveListUpdates } | { modified: false } { if (child) { const parentKey = nn(child._parentKey); @@ -765,7 +790,11 @@ export class LiveList extends AbstractCrdt { child._detach(); return { - modified: makeUpdate(this, [deleteDelta(indexToDelete, previousNode)]), + modified: makeUpdate( + this, + [deleteDelta(indexToDelete, previousNode)], + source + ), reverse, }; } @@ -773,7 +802,7 @@ export class LiveList extends AbstractCrdt { return { modified: false }; } - #applySetChildKeyRemote(newKey: Pos, child: LiveNode): ApplyResult { + #applyRemoteSetChildKey(newKey: Pos, child: LiveNode): ApplyResult { if (this.#implicitlyDeletedItems.has(child)) { this.#implicitlyDeletedItems.delete(child); @@ -782,7 +811,7 @@ export class LiveList extends AbstractCrdt { // TODO: Shift existing item? return { - modified: makeUpdate(this, [insertDelta(newIndex, child)]), + modified: makeUpdate(this, [insertDelta(newIndex, child)], REMOTE), reverse: [], }; } @@ -811,7 +840,11 @@ export class LiveList extends AbstractCrdt { } return { - modified: makeUpdate(this, [moveDelta(previousIndex, newIndex, child)]), + modified: makeUpdate( + this, + [moveDelta(previousIndex, newIndex, child)], + REMOTE + ), reverse: [], }; } else { @@ -831,13 +864,21 @@ export class LiveList extends AbstractCrdt { } return { - modified: makeUpdate(this, [moveDelta(previousIndex, newIndex, child)]), + modified: makeUpdate( + this, + [moveDelta(previousIndex, newIndex, child)], + REMOTE + ), reverse: [], }; } } - #applySetChildKeyAck(newKey: Pos, child: LiveNode): ApplyResult { + #applySetChildKeyAck( + newKey: Pos, + child: LiveNode, + source: UpdateSource + ): ApplyResult { const previousKey = nn(child._parentKey); if (this.#implicitlyDeletedItems.has(child)) { @@ -860,7 +901,7 @@ export class LiveList extends AbstractCrdt { child._setParentLink(this, newKey); const newIndex = this.#insert(child); return { - modified: makeUpdate(this, [insertDelta(newIndex, child)]), + modified: makeUpdate(this, [insertDelta(newIndex, child)], source), reverse: [], }; } else { @@ -898,16 +939,22 @@ export class LiveList extends AbstractCrdt { }; } else { return { - modified: makeUpdate(this, [ - moveDelta(previousIndex, newIndex, child), - ]), + modified: makeUpdate( + this, + [moveDelta(previousIndex, newIndex, child)], + source + ), reverse: [], }; } } } - #applySetChildKeyUndoRedo(newKey: Pos, child: LiveNode): ApplyResult { + #applyLocalSetChildKey( + newKey: Pos, + child: LiveNode, + source: UpdateSource + ): ApplyResult { const previousKey = nn(child._parentKey); const previousIndex = this.#items.findIndex((item) => item === child); @@ -934,7 +981,11 @@ export class LiveList extends AbstractCrdt { } return { - modified: makeUpdate(this, [moveDelta(previousIndex, newIndex, child)]), + modified: makeUpdate( + this, + [moveDelta(previousIndex, newIndex, child)], + source + ), reverse: [ { type: OpCode.SET_PARENT_KEY, @@ -946,19 +997,20 @@ export class LiveList extends AbstractCrdt { } /** @internal */ - _setChildKey(newKey: Pos, child: LiveNode, source: OpSource): ApplyResult { - if (source === OpSource.THEIRS) { - return this.#applySetChildKeyRemote(newKey, child); - } else if (source === OpSource.OURS) { - return this.#applySetChildKeyAck(newKey, child); + _setChildKey(newKey: Pos, child: LiveNode, opSource: OpSource): ApplyResult { + const source = toUpdateSource(opSource); + if (opSource.origin === "remote") { + return this.#applyRemoteSetChildKey(newKey, child); + } else if (!opSource.optimistic) { + return this.#applySetChildKeyAck(newKey, child, source); } else { - return this.#applySetChildKeyUndoRedo(newKey, child); + return this.#applyLocalSetChildKey(newKey, child, source); } } /** @internal */ - _apply(op: Op, isLocal: boolean): ApplyResult { - return super._apply(op, isLocal); + _apply(op: Op, source: OpSource): ApplyResult { + return super._apply(op, source); } /** @internal */ @@ -1005,6 +1057,7 @@ export class LiveList extends AbstractCrdt { * instead of resolving its position against the client's stale view. */ #injectAt(element: TItem, index: number, intent: "insert" | "push"): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); if (index < 0 || index > this.#items.length) { throw new Error( @@ -1031,7 +1084,7 @@ export class LiveList extends AbstractCrdt { intent === "push" ? addIntentToRootOp(ops, "push") : ops, [{ type: OpCode.DELETE_CRDT, id }], new Map>([ - [this._id, makeUpdate(this, [insertDelta(index, value)])], + [this._id, makeUpdate(this, [insertDelta(index, value)], LOCAL_EDIT)], ]) ); } @@ -1043,6 +1096,7 @@ export class LiveList extends AbstractCrdt { * @param targetIndex The index where the element should be after moving. */ move(index: number, targetIndex: number): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); if (targetIndex < 0) { throw new Error("targetIndex cannot be less than 0"); @@ -1087,7 +1141,10 @@ export class LiveList extends AbstractCrdt { if (this._pool && this._id) { const storageUpdates = new Map>([ - [this._id, makeUpdate(this, [moveDelta(index, targetIndex, item)])], + [ + this._id, + makeUpdate(this, [moveDelta(index, targetIndex, item)], LOCAL_EDIT), + ], ]); this._pool.dispatch( @@ -1116,6 +1173,7 @@ export class LiveList extends AbstractCrdt { * @param index The index of the element to delete */ delete(index: number): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); if (index < 0 || index >= this.#items.length) { throw new Error( @@ -1136,7 +1194,7 @@ export class LiveList extends AbstractCrdt { const storageUpdates = new Map>(); storageUpdates.set( nn(this._id), - makeUpdate(this, [deleteDelta(index, item)]) + makeUpdate(this, [deleteDelta(index, item)], LOCAL_EDIT) ); this._pool.dispatch( @@ -1155,6 +1213,7 @@ export class LiveList extends AbstractCrdt { } clear(): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); if (this._pool) { const ops: ClientWireOp[] = []; @@ -1185,7 +1244,10 @@ export class LiveList extends AbstractCrdt { this.invalidate(); const storageUpdates = new Map>(); - storageUpdates.set(nn(this._id), makeUpdate(this, updateDelta)); + storageUpdates.set( + nn(this._id), + makeUpdate(this, updateDelta, LOCAL_EDIT) + ); this._pool.dispatch(ops, reverseOps, storageUpdates); } else { @@ -1198,6 +1260,7 @@ export class LiveList extends AbstractCrdt { } set(index: number, item: TItem): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); if (index < 0 || index >= this.#items.length) { throw new Error( @@ -1224,7 +1287,10 @@ export class LiveList extends AbstractCrdt { value._attach(id, this._pool); const storageUpdates = new Map>(); - storageUpdates.set(this._id, makeUpdate(this, [setDelta(index, value)])); + storageUpdates.set( + this._id, + makeUpdate(this, [setDelta(index, value)], LOCAL_EDIT) + ); const ops = addIntentToRootOp( value._toOpsWithOpId(this._id, position, this._pool), @@ -1436,12 +1502,14 @@ export class LiveList extends AbstractCrdt { function makeUpdate( liveList: LiveList, - deltaUpdates: LiveListUpdateDelta[] + deltaUpdates: LiveListUpdateDelta[], + source: UpdateSource ): LiveListUpdates { return { node: liveList, type: "LiveList", updates: deltaUpdates, + source, }; } diff --git a/packages/liveblocks-core/src/crdts/LiveMap.ts b/packages/liveblocks-core/src/crdts/LiveMap.ts index 747023ab79c..cd66239d5f6 100644 --- a/packages/liveblocks-core/src/crdts/LiveMap.ts +++ b/packages/liveblocks-core/src/crdts/LiveMap.ts @@ -8,7 +8,7 @@ import { CrdtType } from "../protocol/StorageNode"; import type * as DevTools from "../types/DevToolsTreeNode"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import type { ApplyResult, ManagedPool } from "./AbstractCrdt"; -import { AbstractCrdt, OpSource } from "./AbstractCrdt"; +import { AbstractCrdt } from "./AbstractCrdt"; import { creationOpToLiveNode, deserialize, @@ -17,6 +17,8 @@ import { lsonToLiveNode, } from "./liveblocks-helpers"; import type { LiveNode, Lson, ToJson } from "./Lson"; +import type { OpSource, UpdateSource } from "./StorageUpdates"; +import { LOCAL_EDIT, toUpdateSource } from "./StorageUpdates"; import type { UpdateDelta } from "./UpdateDelta"; /** @@ -30,6 +32,7 @@ export type LiveMapUpdates = { // ^^^^^^ // FIXME: `string` is not specific enough here. See if we can // improve this type to match TKey! + source: UpdateSource; }; /** @@ -139,7 +142,12 @@ export class LiveMap< return { modified: false }; } - if (source === OpSource.OURS) { + if (source.origin === "remote") { + // If a remote operation set an item, + // delete the unacknowledgedSet associated to the key + // to make sure any future ack can override it + this.#unacknowledgedSet.delete(key); + } else if (!source.optimistic) { const lastUpdateOpId = this.#unacknowledgedSet.get(key); if (lastUpdateOpId === opId) { // Acknowlegment from local operation @@ -149,11 +157,6 @@ export class LiveMap< // Another local set has overriden the value, so we do nothing return { modified: false }; } - } else if (source === OpSource.THEIRS) { - // If a remote operation set an item, - // delete the unacknowledgedSet associated to the key - // to make sure any future ack can override it - this.#unacknowledgedSet.delete(key); } const previousValue = this.#map.get(key); @@ -176,6 +179,7 @@ export class LiveMap< node: this, type: "LiveMap", updates: { [key]: { type: "update" } }, + source: toUpdateSource(source), }, reverse, }; @@ -191,7 +195,7 @@ export class LiveMap< } /** @internal */ - _detachChild(child: LiveNode): ApplyResult { + _detachChild(child: LiveNode, source: UpdateSource): ApplyResult { const id = nn(this._id); const parentKey = nn(child._parentKey); const reverse = child._toOps(id, parentKey); @@ -214,6 +218,7 @@ export class LiveMap< deletedItem: liveNodeToLson(child), }, }, + source, }; return { modified: storageUpdate, reverse }; @@ -253,6 +258,7 @@ export class LiveMap< * @param value The value of the element to add. Should be serializable to JSON. */ set(key: TKey, value: TValue): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); const oldValue = this.#map.get(key); @@ -275,6 +281,7 @@ export class LiveMap< node: this, type: "LiveMap", updates: { [key]: { type: "update" } }, + source: LOCAL_EDIT, }); const ops = item._toOpsWithOpId(this._id, key, this._pool); @@ -312,6 +319,7 @@ export class LiveMap< * @returns true if an element existed and has been removed, or false if the element does not exist. */ delete(key: TKey): boolean { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); const item = this.#map.get(key); @@ -335,6 +343,7 @@ export class LiveMap< deletedItem: liveNodeToLson(item), }, }, + source: LOCAL_EDIT, }); this._pool.dispatch( [ diff --git a/packages/liveblocks-core/src/crdts/LiveObject.ts b/packages/liveblocks-core/src/crdts/LiveObject.ts index df7cba3a2ca..86225cd4134 100644 --- a/packages/liveblocks-core/src/crdts/LiveObject.ts +++ b/packages/liveblocks-core/src/crdts/LiveObject.ts @@ -27,7 +27,7 @@ import type * as DevTools from "../types/DevToolsTreeNode"; import type { KnownKeys } from "../types/KnownKeys"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import type { ApplyResult, ManagedPool } from "./AbstractCrdt"; -import { AbstractCrdt, OpSource } from "./AbstractCrdt"; +import { AbstractCrdt } from "./AbstractCrdt"; import { creationOpToLson, deserializeToLson, @@ -37,6 +37,8 @@ import { } from "./liveblocks-helpers"; import type { SyncConfig } from "./reconcile"; import { reconcileLiveObject } from "./reconcile"; +import type { OpSource, UpdateSource } from "./StorageUpdates"; +import { LOCAL_EDIT, toUpdateSource } from "./StorageUpdates"; import type { UpdateDelta } from "./UpdateDelta"; /** @@ -70,6 +72,7 @@ export type LiveObjectUpdates = { type: "LiveObject"; node: LiveObject; updates: LiveObjectUpdateDelta; + source: UpdateSource; }; /** @@ -258,7 +261,7 @@ export class LiveObject extends AbstractCrdt { return { modified: false }; } - if (source === OpSource.LOCAL) { + if (source.origin === "local" && source.optimistic) { // Track locally-generated opId to preserve optimistic update this.#unackedOpsByKey.set(key, nn(opId)); } else if (this.#unackedOpsByKey.get(key) === undefined) { @@ -305,12 +308,13 @@ export class LiveObject extends AbstractCrdt { node: this, type: "LiveObject", updates: { [key]: { type: "update" } }, + source: toUpdateSource(source), }, }; } /** @internal */ - _detachChild(child: LiveNode): ApplyResult { + _detachChild(child: LiveNode, source: UpdateSource): ApplyResult { if (child) { const id = nn(this._id); const parentKey = nn(child._parentKey); @@ -332,6 +336,7 @@ export class LiveObject extends AbstractCrdt { updates: { [parentKey]: { type: "delete", deletedItem }, } as { [K in keyof O]: UpdateDelta }, + source, }; return { modified: storageUpdate, reverse }; @@ -352,14 +357,14 @@ export class LiveObject extends AbstractCrdt { } /** @internal */ - _apply(op: Op, isLocal: boolean): ApplyResult { + _apply(op: Op, source: OpSource): ApplyResult { if (op.type === OpCode.UPDATE_OBJECT) { - return this.#applyUpdate(op, isLocal); + return this.#applyUpdate(op, source); } else if (op.type === OpCode.DELETE_OBJECT_KEY) { - return this.#applyDeleteObjectKey(op, isLocal); + return this.#applyDeleteObjectKey(op, source); } - return super._apply(op, isLocal); + return super._apply(op, source); } /** @internal */ @@ -389,7 +394,7 @@ export class LiveObject extends AbstractCrdt { } } - #applyUpdate(op: UpdateObjectOp, isLocal: boolean): ApplyResult { + #applyUpdate(op: UpdateObjectOp, source: OpSource): ApplyResult { let isModified = false; const id = nn(this._id); const reverse: Op[] = []; @@ -420,7 +425,7 @@ export class LiveObject extends AbstractCrdt { continue; } - if (isLocal) { + if (source.origin === "local" && source.optimistic) { // Track locally-generated opId to preserve optimistic update this.#unackedOpsByKey.set(key, nn(op.opId)); } else if (this.#unackedOpsByKey.get(key) === undefined) { @@ -458,13 +463,14 @@ export class LiveObject extends AbstractCrdt { node: this, type: "LiveObject", updates: updateDelta, + source: toUpdateSource(source), }, reverse, } : { modified: false }; } - #applyDeleteObjectKey(op: DeleteObjectKeyOp, isLocal: boolean): ApplyResult { + #applyDeleteObjectKey(op: DeleteObjectKeyOp, source: OpSource): ApplyResult { const key = op.key; // If property does not exist, exit without notifying @@ -475,7 +481,10 @@ export class LiveObject extends AbstractCrdt { // If a local operation exists on the same key and we receive a remote // one prevent flickering by not applying delete op. - if (!isLocal && this.#unackedOpsByKey.get(key) !== undefined) { + if ( + !(source.origin === "local" && source.optimistic) && + this.#unackedOpsByKey.get(key) !== undefined + ) { return { modified: false }; } @@ -504,6 +513,7 @@ export class LiveObject extends AbstractCrdt { updates: { [op.key]: { type: "delete", deletedItem: oldValue satisfies Lson }, }, + source: toUpdateSource(source), }, reverse, }; @@ -540,6 +550,7 @@ export class LiveObject extends AbstractCrdt { key: TKey, value: Extract, Json> ): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); // Prepare synced-key deletion (if applicable) — does NOT dispatch yet @@ -565,6 +576,7 @@ export class LiveObject extends AbstractCrdt { ...existing?.updates, [key]: { type: "update" } satisfies UpdateDelta, } as { [K in keyof O]: UpdateDelta }, + source: LOCAL_EDIT, }); this._pool.dispatch(ops, reverse, storageUpdates); @@ -597,6 +609,7 @@ export class LiveObject extends AbstractCrdt { storageUpdates: Map>, ] | null { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); const k = key as string; @@ -619,6 +632,7 @@ export class LiveObject extends AbstractCrdt { deletedItem: oldValue, } satisfies UpdateDelta, } as { [K in keyof O]: UpdateDelta }, + source: LOCAL_EDIT, }); return [[], [], storageUpdates]; } @@ -677,6 +691,7 @@ export class LiveObject extends AbstractCrdt { } as { [K in keyof O]: UpdateDelta; }, + source: LOCAL_EDIT, }); return [ops, reverse, storageUpdates]; @@ -699,6 +714,7 @@ export class LiveObject extends AbstractCrdt { * @param patch The object used to overrides properties */ update(patch: Partial): void { + this._warnIfOrphaned(); this._pool?.assertStorageIsWritable(); // If detectLargeObjects is enabled, perform a runtime size check now so we @@ -854,6 +870,7 @@ export class LiveObject extends AbstractCrdt { node: this, type: "LiveObject", updates: updateDelta, + source: LOCAL_EDIT, }); this._pool.dispatch(ops, reverseOps, storageUpdates); } diff --git a/packages/liveblocks-core/src/crdts/LiveRegister.ts b/packages/liveblocks-core/src/crdts/LiveRegister.ts index 3d4a904e7a7..eb078fa706a 100644 --- a/packages/liveblocks-core/src/crdts/LiveRegister.ts +++ b/packages/liveblocks-core/src/crdts/LiveRegister.ts @@ -14,6 +14,7 @@ import type * as DevTools from "../types/DevToolsTreeNode"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import type { ApplyResult, ManagedPool } from "./AbstractCrdt"; import { AbstractCrdt } from "./AbstractCrdt"; +import type { OpSource } from "./StorageUpdates"; /** * INTERNAL @@ -85,8 +86,8 @@ export class LiveRegister extends AbstractCrdt { } /** @internal */ - _apply(op: Op, isLocal: boolean): ApplyResult { - return super._apply(op, isLocal); + _apply(op: Op, source: OpSource): ApplyResult { + return super._apply(op, source); } /** @internal */ diff --git a/packages/liveblocks-core/src/crdts/LiveText.ts b/packages/liveblocks-core/src/crdts/LiveText.ts new file mode 100644 index 00000000000..70b245c8a24 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/LiveText.ts @@ -0,0 +1,969 @@ +import { kInternal } from "../internal"; +import { nn } from "../lib/assert"; +import * as console from "../lib/fancy-console"; +import type { JsonObject, ReadonlyJson } from "../lib/Json"; +import { nanoid } from "../lib/nanoid"; +import { stableStringify } from "../lib/stringify"; +import type { + CreateOp, + CreateTextOp, + LiveTextData, + Op, + TextAttributes, + TextOperation, + UpdateTextOp, +} from "../protocol/Op"; +import { OpCode } from "../protocol/Op"; +import type { SerializedText, TextStorageNode } from "../protocol/StorageNode"; +import { CrdtType } from "../protocol/StorageNode"; +import type * as DevTools from "../types/DevToolsTreeNode"; +import type { ParentToChildNodeMap } from "../types/NodeMap"; +import type { + ApplyResult, + ManagedPool, + PrivateLiveNodeApi, +} from "./AbstractCrdt"; +import { AbstractCrdt } from "./AbstractCrdt"; +import { + applyDelete, + applyFormat, + applyInsert, + applyTextOperationsToSegments, + clipIndexToCodePointBoundary, + clipRangeToCodePointBoundaries, + dataToSegments, + inverseMapTextIndexThroughOperations, + invertTextOperations, + mapTextIndexThroughOperations, + segmentsToData, + textLength, + textOperationsEqual, + type TextSegment, + transformTextOperations, + transformTextOperationsX, +} from "./liveTextOps"; +import type { LiveNode } from "./Lson"; +import type { OpSource, StorageUpdate, UpdateSource } from "./StorageUpdates"; +import { LOCAL_EDIT, toUpdateSource } from "./StorageUpdates"; + +export type LiveTextAttributes = TextAttributes; +export type LiveTextAttributesPatch = JsonObject; +export type { + LiveTextData, + TextOperation as LiveTextOperation, + LiveTextSegment, +} from "../protocol/Op"; + +export type LiveTextChange = + | { + /** Text was inserted at {@link LiveTextChange.index}. */ + readonly type: "insert"; + readonly index: number; + readonly text: string; + readonly attributes?: TextAttributes; + } + | { + /** Text was deleted starting at {@link LiveTextChange.index}. */ + readonly type: "delete"; + readonly index: number; + readonly length: number; + readonly deletedText: string; + } + | { + /** Inline attributes were updated on a range of text. */ + readonly type: "format"; + readonly index: number; + readonly length: number; + readonly attributes: LiveTextAttributesPatch; + }; + +/** Notification payload when a {@link LiveText} node changes. */ +export type LiveTextUpdates = { + type: "LiveText"; + node: LiveText; + version: number; + updates: LiveTextChange[]; + source: UpdateSource; +}; + +/** + * An accepted (server-ordered) update, recorded in *locally-applied* form: + * the ops as they were actually applied to this client's document (i.e. + * after transformation over the local pending ops). Acknowledgements of our + * own ops apply nothing locally, so they are recorded with empty ops. This + * makes the entries directly usable for bringing replayed (undo/redo) ops + * up to date: chaining transforms through these entries moves an op from + * the local document state at `baseVersion` to the current local state. + */ +type AcceptedTextOperations = { + version: number; + opId?: string; + ops: readonly TextOperation[]; +}; + +/** + * @private + * + * Private methods on a LiveText node. As a user of Liveblocks, NEVER USE ANY + * OF THESE DIRECTLY, because bad things will probably happen if you do. + */ +export type PrivateLiveTextApi = PrivateLiveNodeApi & { + /** + * Encode a local-document index into server-confirmed coordinates suitable + * for broadcasting to peers via presence or any other side channel. Pair + * the result with {@link LiveText.version} at the same instant when + * sending. + */ + encodeIndex(localIndex: number): number; + + /** + * Decode an `(index, fromVersion)` pair from a peer into an offset in this + * LiveText's current local document. + */ + decodeIndex(index: number, fromVersion: number): number | null; +}; + +const ACCEPTED_OPS_HISTORY_LIMIT = 1000; + +export { + applyLiveTextOperations, + mapTextIndexThroughOperations, + transformTextOperations, +} from "./liveTextOps"; + +/** + * LiveText is a collaborative rich-text primitive built on server-ordered + * operational transformation. + * + * Use it to store plain text with optional inline formatting attributes in + * Liveblocks Storage. Each document is a flat sequence of text segments; it + * cannot contain child Storage structures. + * + * Outbound model (one-in-flight): at most one UpdateTextOp per node is + * awaiting server acknowledgement at any time. Local edits made while an op + * is in flight are queued and sent (composed into a single op) once the ack + * arrives. This guarantees every wire op is expressed against server-state + * coordinates, so the server can transform it over exactly the (foreign) + * ops the client hadn't seen — never over the client's own pending ops. + * + * Inbound model: accepted remote ops are transformed over the local pending + * ops before being applied ("before" order: the accepted op wins ties), and + * the pending ops are re-expressed over the remote op in turn ("after" + * order), keeping them in server coordinates at all times. + * + * @example + * const text = new LiveText("Hello"); + * text.insert(5, " world"); + * text.format(0, 5, { bold: true }); + * + * // [["Hello", { bold: true }], [" world"]] + * text.toJSON(); + * + * @example + * // Use in Storage + * declare global { + * interface Liveblocks { + * Storage: { document: LiveText }; + * } + * } + * + * const { root } = await room.getStorage(); + * root.get("document").replace(0, root.get("document").length, "Updated"); + */ +export class LiveText extends AbstractCrdt { + /** + * @private + * + * Private methods and variables used in the core internals, but as a user + * of Liveblocks, NEVER USE ANY OF THESE DIRECTLY, because bad things + * will probably happen if you do. + */ + declare readonly [kInternal]: PrivateLiveTextApi; + + /** The local document: #confirmed ⊕ #inFlightOps ⊕ #queuedOps. */ + #segments: TextSegment[]; + /** The server-confirmed document (only authoritative ops applied). */ + #confirmed: TextSegment[]; + #version: number; + + /** The op currently awaiting server acknowledgement (at most one). */ + #inFlightOpId?: string; + /** Its ops, continuously re-expressed against current server state. */ + #inFlightOps: TextOperation[] = []; + /** Local edits made while an op is in flight; sent after the ack. */ + #queuedOps: TextOperation[] = []; + + #acceptedOps: AcceptedTextOperations[] = []; + + /** + * Creates a new LiveText document. + * + * @param textOrData Initial plain text, or an array of `[text]` / + * `[text, attributes]` segments. Defaults to an empty document. + * + * @example + * new LiveText(); + * new LiveText("Hello world"); + * new LiveText([["Hello ", { bold: true }], ["world"]]); + */ + constructor(textOrData: string | LiveTextData = "", version = 0) { + super(); + this.#segments = + typeof textOrData === "string" + ? textOrData.length === 0 + ? [] + : [{ text: textOrData }] + : dataToSegments(textOrData); + this.#confirmed = [...this.#segments]; + this.#version = version; + + Object.assign(this[kInternal], { + encodeIndex: (localIndex: number) => this.#encodeIndex(localIndex), + decodeIndex: (index: number, fromVersion: number) => + this.#decodeIndex(index, fromVersion), + }); + } + + get version(): number { + return this.#version; + } + + get length(): number { + return textLength(this.#segments); + } + + /** @internal */ + static _deserialize( + [id, item]: TextStorageNode, + _parentToChildren: ParentToChildNodeMap, + pool: ManagedPool + ): LiveText { + const text = new LiveText(item.data, item.version); + text._attach(id, pool); + return text; + } + + /** @internal */ + _toOps(parentId: string, parentKey: string): CreateTextOp[] { + if (this._id === undefined) { + throw new Error("Cannot serialize LiveText if it is not attached"); + } + + return [ + { + type: OpCode.CREATE_TEXT, + id: this._id, + parentId, + parentKey, + data: this.toJSON(), + version: this.#version, + }, + ]; + } + + /** @internal */ + _serialize(): SerializedText { + if (this.parent.type !== "HasParent") { + throw new Error("Cannot serialize LiveText if parent is missing"); + } + + return { + type: CrdtType.TEXT, + parentId: nn(this.parent.node._id, "Parent node expected to have ID"), + parentKey: this.parent.key, + data: this.toJSON(), + version: this.#version, + }; + } + + /** @internal */ + _attachChild(_op: CreateOp): ApplyResult { + throw new Error("LiveText cannot contain child nodes"); + } + + /** @internal */ + _detachChild(_crdt: LiveNode): ApplyResult { + throw new Error("LiveText cannot contain child nodes"); + } + + /** @internal */ + _apply(op: Op, source: OpSource): ApplyResult { + if (op.type !== OpCode.UPDATE_TEXT) { + return super._apply(op, source); + } + + if (source.origin === "local" && source.optimistic) { + return this.#applyLocal(op, toUpdateSource(source)); + } + + if (op.opId !== undefined && op.opId === this.#inFlightOpId) { + return this.#applyAck(op, toUpdateSource(source)); + } + + if ( + op.opId !== undefined && + this.#acceptedOps.some((entry) => entry.opId === op.opId) + ) { + // Duplicate acknowledgement of an op we already integrated. + this.#version = Math.max(this.#version, op.version ?? op.baseVersion + 1); + return { modified: false }; + } + + return this.#applyRemote(op, toUpdateSource(source)); + } + + /** + * Inserts text at the given index. + * + * @param index Character index at which to insert. Values outside the + * document range are clipped. + * @param text Text to insert. + * @param attributes Optional inline attributes for the inserted text. + * + * @example + * const text = new LiveText("Hello"); + * text.insert(5, " world"); + * text.insert(0, "Say: ", { italic: true }); + */ + insert(index: number, text: string, attributes?: TextAttributes): void { + const clippedIndex = clipIndexToCodePointBoundary(this.toString(), index); + this.#dispatch([{ type: "insert", index: clippedIndex, text, attributes }]); + } + + /** + * Deletes `length` characters starting at `index`. + * + * @example + * const text = new LiveText("Hello world"); + * text.delete(5, 6); // "Hello" + */ + delete(index: number, length: number): void { + const clipped = clipRangeToCodePointBoundaries( + this.toString(), + index, + length + ); + if (clipped.length === 0) { + return; + } + this.#dispatch([ + { type: "delete", index: clipped.index, length: clipped.length }, + ]); + } + + /** + * Replaces a range of text with new text. + * + * @example + * const text = new LiveText("Hello world"); + * text.replace(0, 5, "Hi"); // "Hi world" + */ + replace( + index: number, + length: number, + text: string, + attributes?: TextAttributes + ): void { + const clipped = clipRangeToCodePointBoundaries( + this.toString(), + index, + length + ); + const ops: TextOperation[] = []; + if (clipped.length > 0) { + ops.push({ + type: "delete", + index: clipped.index, + length: clipped.length, + }); + } + if (text.length > 0) { + ops.push({ type: "insert", index: clipped.index, text, attributes }); + } + this.#dispatch(ops); + } + + /** + * Encode a local-document index (an offset into this LiveText's current + * #segments, which CodeMirror or any consumer mirrors as its document) + * into server-confirmed coordinates suitable for broadcasting to peers via + * presence or any other side channel. + * + * The returned index is in this LiveText's current #confirmed coordinates + * — that is, with this client's local pending ops inverse-mapped out. + * Pair it with the current {@link LiveText.version} when sending so the + * receiver can call {@link PrivateLiveTextApi.decodeIndex} to land the + * position in their own local document coordinates regardless of their + * private pending ops. + * + * Index ambiguity at boundaries is resolved by an inverse-of-forward + * convention: a position at or before a local insertion is reported as + * the position right before the insertion in #confirmed; a position past + * the insertion shifts left by the insertion's length. Positions inside + * an own-pending insertion collapse to the insertion point. + */ + #encodeIndex(localIndex: number): number { + let mapped = Math.max(0, Math.min(localIndex, this.length)); + mapped = inverseMapTextIndexThroughOperations(mapped, this.#queuedOps); + mapped = inverseMapTextIndexThroughOperations(mapped, this.#inFlightOps); + return mapped; + } + + /** + * Decode an `(index, fromVersion)` pair produced by + * {@link PrivateLiveTextApi.encodeIndex} — typically on a peer — into an + * offset in this LiveText's current local document (an index suitable for + * placing a CodeMirror marker, an annotation anchor, or anything else that + * lives over #segments). + * + * Composes the accepted ops applied since `fromVersion` (drawn from + * #acceptedOps in locally-applied form) with this client's own local + * pending ops, in that order. The result is in current #segments + * coordinates. + * + * Returns `null` when the position cannot be decoded against the current + * state: + * - `fromVersion` is greater than this LiveText's current version: the + * peer is ahead of us. The caller should park the message and retry + * after more accepted ops arrive. + * - `fromVersion` falls outside the retained accepted-ops history. This + * only happens after very long-lived disconnections; the caller can + * fall back to using the raw index and letting subsequent local + * transactions map it (with bounded drift). + */ + #decodeIndex(index: number, fromVersion: number): number | null { + if (fromVersion > this.#version) { + return null; + } + if (fromVersion < this.#version) { + const oldest = this.#acceptedOps[0]?.version; + if (oldest === undefined || oldest > fromVersion + 1) { + return null; + } + } + + let mapped = index; + for (const entry of this.#acceptedOps) { + if (entry.version <= fromVersion) continue; + if (entry.version > this.#version) break; + if (entry.ops.length === 0) continue; + mapped = mapTextIndexThroughOperations(mapped, entry.ops); + } + mapped = mapTextIndexThroughOperations(mapped, this.#inFlightOps); + mapped = mapTextIndexThroughOperations(mapped, this.#queuedOps); + return Math.max(0, Math.min(mapped, this.length)); + } + + /** + * Applies or removes inline attributes on a range of text. + * + * Set an attribute to `null` to remove it from the range. + * + * @example + * const text = new LiveText("Hello world"); + * text.format(0, 5, { bold: true }); + * text.format(0, 5, { bold: null }); + */ + format( + index: number, + length: number, + attributes: LiveTextAttributesPatch + ): void { + const clipped = clipRangeToCodePointBoundaries( + this.toString(), + index, + length + ); + if (clipped.length === 0) { + return; + } + this.#dispatch([ + { + type: "format", + index: clipped.index, + length: clipped.length, + attributes, + }, + ]); + } + + /** Local edits made through the public API. */ + #dispatch(ops: readonly TextOperation[]): void { + if (ops.length === 0) { + return; + } + + this._warnIfOrphaned(); + this._pool?.assertStorageIsWritable(); + const attached = this._pool !== undefined && this._id !== undefined; + const reverse = attached ? this.#invertOperations(ops) : []; + const changes = this.#applyOperationsLocally(ops); + + if (!attached) { + return; + } + + const pool = nn(this._pool); + const id = nn(this._id); + const updates = new Map([ + [ + id, + { + type: "LiveText", + node: this, + version: this.#version, + updates: changes, + source: LOCAL_EDIT, + }, + ], + ]); + + if (this.#inFlightOpId === undefined) { + const opId = pool.generateOpId(); + this.#inFlightOpId = opId; + this.#inFlightOps = [...ops]; + pool.dispatch( + [ + { + type: OpCode.UPDATE_TEXT, + id, + opId, + baseVersion: this.#version, + ops: [...ops], + }, + ], + reverse, + updates + ); + } else { + // An op is awaiting acknowledgement: queue these edits. They will be + // sent (as a single composed op) when the ack arrives. The dispatch + // still needs to register the reverse ops and clear the redo stack, + // like any fresh local mutation. + this.#queuedOps.push(...ops); + pool.dispatch([], reverse, updates, { clearRedoStack: true }); + } + } + + /** + * A local replay of an existing wire op: an undo/redo frame, or an + * unacknowledged op re-sent after a reconnect. + */ + #applyLocal(op: UpdateTextOp, source: UpdateSource): ApplyResult { + const mutableOp = op as { baseVersion: number; ops: TextOperation[] }; + + // Re-sent offline op (reconnect): its content is already applied + // locally. Compose any queued ops into it and refresh its authoritative + // fields so the server sees current server-state coordinates. + if (op.opId !== undefined && op.opId === this.#inFlightOpId) { + this.#inFlightOps = [...this.#inFlightOps, ...this.#queuedOps]; + this.#queuedOps = []; + mutableOp.baseVersion = this.#version; + mutableOp.ops = [...this.#inFlightOps]; + return { modified: false }; + } + + // Replayed undo/redo frame: transform it over everything accepted since + // it was created. Accepted entries are recorded in locally-applied form, + // so this moves the op into current local-document coordinates. (Our own + // acknowledgements record empty ops — applying nothing locally — so the + // frame is never transformed over the very op it inverts.) + let ops: readonly TextOperation[] = op.ops; + for (const entry of this.#acceptedOps) { + if (entry.version > op.baseVersion && entry.ops.length > 0) { + ops = transformTextOperations(ops, entry.ops, "after"); + } + } + + const reverse = this.#invertOperations(ops); + const changes = this.#applyOperationsLocally(ops); + + if (this.#inFlightOpId === undefined && ops.length > 0) { + this.#inFlightOpId = nn(op.opId, "Local ops must have an opId"); + this.#inFlightOps = [...ops]; + mutableOp.baseVersion = this.#version; + mutableOp.ops = [...ops]; + } else { + // Another op is in flight (or the ops transformed away entirely): + // queue the content and turn the wire op into an empty vehicle. The + // server ignores empty updates (acks without applying), and the + // queued content rides along after the in-flight ack. + this.#queuedOps.push(...ops); + mutableOp.baseVersion = this.#version; + mutableOp.ops = []; + } + + if (changes.length === 0) { + return { modified: false }; + } + + return { + reverse, + modified: { + type: "LiveText", + node: this, + version: this.#version, + updates: changes, + source, + }, + }; + } + + /** Server acknowledgement of our in-flight op. */ + #applyAck(op: UpdateTextOp, source: UpdateSource): ApplyResult { + const ackedVersion = + op.version ?? Math.max(this.#version, op.baseVersion + 1); + const predicted = this.#inFlightOps; + const opId = this.#inFlightOpId; + + this.#confirmed = applyTextOperationsToSegments(this.#confirmed, op.ops); + this.#inFlightOpId = undefined; + this.#inFlightOps = []; + + let appliedOps: TextOperation[] = []; + let result: ApplyResult = { modified: false }; + + if (!textOperationsEqual(op.ops, predicted)) { + // The authoritative ops differ from our continuously re-expressed + // prediction. This should not happen as long as client and server run + // the same transform; recover by rebuilding the local document from + // the confirmed state. + console.error( + "LiveText: acknowledgement did not match the local prediction; resynchronizing" + ); + const rebuilt = this.#rebuildLocalFromConfirmed(); + appliedOps = rebuilt.appliedOps; + if (rebuilt.changes.length > 0) { + result = { + reverse: [], + modified: { + type: "LiveText", + node: this, + version: ackedVersion, + updates: rebuilt.changes, + source, + }, + }; + } + } + + this.#version = Math.max(this.#version, ackedVersion); + this.#recordAccepted(ackedVersion, appliedOps, opId); + this.#flushQueued(); + return result; + } + + /** An accepted op from another client (or a server-fabricated fix op). */ + #applyRemote(op: UpdateTextOp, source: UpdateSource): ApplyResult { + const version = op.version ?? this.#version + 1; + + // Advance the confirmed state with the authoritative ops as-is. + this.#confirmed = applyTextOperationsToSegments(this.#confirmed, op.ops); + + // Transform the remote op over our pending ops (the remote op is ordered + // before them), and re-express the pending ops over the remote op. + const [overInFlight, inFlight] = transformTextOperationsX( + op.ops, + this.#inFlightOps, + "before" + ); + const [applied, queued] = transformTextOperationsX( + overInFlight, + this.#queuedOps, + "before" + ); + this.#inFlightOps = inFlight; + this.#queuedOps = queued; + + this.#recordAccepted(version, applied, op.opId); + + if (applied.length === 0) { + this.#version = Math.max(this.#version, version); + return { modified: false }; + } + + const reverse = this.#invertOperations(applied); + const changes = this.#applyOperationsLocally(applied); + this.#version = Math.max(this.#version, version); + + return { + reverse, + modified: { + type: "LiveText", + node: this, + version: this.#version, + updates: changes, + source, + }, + }; + } + + /** Send the queued ops as the next in-flight op (after an ack). */ + #flushQueued(): void { + if ( + this.#queuedOps.length === 0 || + this._pool === undefined || + this._id === undefined + ) { + return; + } + + const opId = this._pool.generateOpId(); + this.#inFlightOpId = opId; + this.#inFlightOps = this.#queuedOps; + this.#queuedOps = []; + this._pool.dispatch( + [ + { + type: OpCode.UPDATE_TEXT, + id: this._id, + opId, + baseVersion: this.#version, + ops: [...this.#inFlightOps], + }, + ], + [], + new Map(), + // The local content was already applied (and made undoable) when the + // edits happened; this is purely an outbound flush. + { clearRedoStack: false } + ); + } + + /** + * Rebuild the local document as confirmed ⊕ queued ops, returning the + * coarse delta that was applied. Only used by defensive recovery paths. + */ + #rebuildLocalFromConfirmed(): { + appliedOps: TextOperation[]; + changes: LiveTextChange[]; + } { + const before = this.#segments; + const after = applyTextOperationsToSegments(this.#confirmed, [ + ...this.#inFlightOps, + ...this.#queuedOps, + ]); + + if ( + stableStringify(segmentsToData(before)) === + stableStringify(segmentsToData(after)) + ) { + this.#segments = after; + return { appliedOps: [], changes: [] }; + } + + const beforeText = before.map((segment) => segment.text).join(""); + this.#segments = after; + this.invalidate(); + + const appliedOps: TextOperation[] = []; + const changes: LiveTextChange[] = []; + if (beforeText.length > 0) { + appliedOps.push({ type: "delete", index: 0, length: beforeText.length }); + changes.push({ + type: "delete", + index: 0, + length: beforeText.length, + deletedText: beforeText, + }); + } + let index = 0; + for (const segment of after) { + appliedOps.push({ + type: "insert", + index, + text: segment.text, + attributes: segment.attributes, + }); + changes.push({ + type: "insert", + index, + text: segment.text, + attributes: segment.attributes, + }); + index += segment.text.length; + } + return { appliedOps, changes }; + } + + /** + * Reconcile this node against an authoritative storage snapshot (e.g. + * after a reconnect). The confirmed state and version are replaced by the + * snapshot's; pending (in-flight + queued) ops are preserved on top and + * will be re-sent by the offline-ops replay. + * + * @internal + */ + _resyncText( + data: LiveTextData, + version: number, + source: UpdateSource + ): LiveTextUpdates | undefined { + this.#confirmed = dataToSegments(data); + this.#version = version; + // Accepted-op history is expressed against the pre-snapshot timeline and + // is no longer meaningful. + this.#acceptedOps = []; + + const rebuilt = this.#rebuildLocalFromConfirmed(); + if (rebuilt.changes.length === 0) { + return undefined; + } + + return { + type: "LiveText", + node: this, + version: this.#version, + updates: rebuilt.changes, + source, + }; + } + + /** + * Called when the server rejected one of our ops. Drops all pending state + * for this node (edits queued behind a rejected op cannot be trusted + * either); the room follows up with a storage resync. + * + * @internal + */ + _rejectPendingOp(opId: string): void { + if (opId !== this.#inFlightOpId) { + return; + } + this.#inFlightOpId = undefined; + this.#inFlightOps = []; + this.#queuedOps = []; + } + + #recordAccepted( + version: number, + ops: readonly TextOperation[], + opId: string | undefined + ): void { + if (this.#acceptedOps.some((entry) => entry.version === version)) { + return; + } + + this.#acceptedOps.push({ version, opId, ops: [...ops] }); + this.#acceptedOps.sort((left, right) => left.version - right.version); + if (this.#acceptedOps.length > ACCEPTED_OPS_HISTORY_LIMIT) { + this.#acceptedOps.splice( + 0, + this.#acceptedOps.length - ACCEPTED_OPS_HISTORY_LIMIT + ); + } + } + + #applyOperationsLocally(ops: readonly TextOperation[]): LiveTextChange[] { + const changes: LiveTextChange[] = []; + for (const op of ops) { + if (op.type === "insert") { + this.#segments = applyInsert( + this.#segments, + op.index, + op.text, + op.attributes + ); + changes.push({ + type: "insert", + index: op.index, + text: op.text, + attributes: op.attributes, + }); + } else if (op.type === "delete") { + const result = applyDelete(this.#segments, op.index, op.length); + this.#segments = result.segments; + changes.push({ + type: "delete", + index: op.index, + length: op.length, + deletedText: result.deletedText, + }); + } else { + this.#segments = applyFormat( + this.#segments, + op.index, + op.length, + op.attributes + ); + changes.push({ + type: "format", + index: op.index, + length: op.length, + attributes: op.attributes, + }); + } + } + this.invalidate(); + return changes; + } + + #invertOperations(ops: readonly TextOperation[]): UpdateTextOp[] { + return [ + { + type: OpCode.UPDATE_TEXT, + id: nn(this._id), + baseVersion: this.#version, + ops: invertTextOperations(this.#segments, ops), + }, + ]; + } + + /** Returns the plain text content without attributes. Equivalent to joining the text from each segment in {@link LiveText.toJSON}. */ + toString(): string { + return this.#segments.map((segment) => segment.text).join(""); + } + + /** + * Returns a JSON-compatible snapshot of the document as a {@link LiveTextData} + * array. + * + * @example + * new LiveText([["Hello ", { bold: true }], ["world"]]).toJSON(); + * // [["Hello ", { bold: true }], ["world"]] + */ + toJSON(): LiveTextData { + return super.toJSON() as LiveTextData; + } + + /** @internal */ + _toJSON(): ReadonlyJson { + return segmentsToData(this.#segments) as ReadonlyJson; + } + + /** @internal */ + toTreeNode(key: string): DevTools.LiveTreeNode<"LiveText"> { + return super.toTreeNode(key) as DevTools.LiveTreeNode<"LiveText">; + } + + /** @internal */ + _toTreeNode(key: string): DevTools.LsonTreeNode { + const nodeId = this._id ?? nanoid(); + const payload: DevTools.LsonTreeNode[] = this.toJSON().map( + (segment, index) => ({ + type: "Json", + id: `${nodeId}:${index}`, + key: String(index), + payload: segment, + }) + ); + + payload.push({ + type: "Json", + id: `${nodeId}:version`, + key: "version", + payload: this.version, + }); + + return { + type: "LiveText", + id: nodeId, + key, + payload, + }; + } + + clone(): LiveText { + return new LiveText(this.toJSON(), this.#version); + } +} diff --git a/packages/liveblocks-core/src/crdts/Lson.ts b/packages/liveblocks-core/src/crdts/Lson.ts index eae6642dc37..fe108903a2f 100644 --- a/packages/liveblocks-core/src/crdts/Lson.ts +++ b/packages/liveblocks-core/src/crdts/Lson.ts @@ -3,12 +3,14 @@ import type { LiveList } from "../crdts/LiveList"; import type { LiveMap } from "../crdts/LiveMap"; import type { LiveObject } from "../crdts/LiveObject"; import type { LiveRegister } from "../crdts/LiveRegister"; +import type { LiveText, LiveTextData } from "../crdts/LiveText"; import type { Json, ReadonlyJson, ReadonlyJsonObject } from "../lib/Json"; export type LiveStructure = | LiveObject | LiveList | LiveMap + | LiveText | LiveFile; /** @@ -72,6 +74,10 @@ export type ToJson = Lson extends V ? ReadonlyJsonObject : { readonly [K in KS]: ToJson } : + // A LiveText serializes to a delta so inline attributes are preserved + L extends LiveText ? + LiveTextData : + // A LiveFile serializes to its immutable metadata L extends LiveFile ? LiveFileData : diff --git a/packages/liveblocks-core/src/crdts/StorageUpdates.ts b/packages/liveblocks-core/src/crdts/StorageUpdates.ts index 282ba1e1a0a..e25f5991ade 100644 --- a/packages/liveblocks-core/src/crdts/StorageUpdates.ts +++ b/packages/liveblocks-core/src/crdts/StorageUpdates.ts @@ -1,17 +1,85 @@ import type { LiveListUpdates } from "../crdts/LiveList"; import type { LiveMapUpdates } from "../crdts/LiveMap"; import type { LiveObjectUpdates } from "../crdts/LiveObject"; +import type { LiveTextUpdates } from "../crdts/LiveText"; import type { Lson, LsonObject } from "../crdts/Lson"; +import { freeze } from "../lib/freeze"; export type StorageCallback = (updates: StorageUpdate[]) => void; export type LiveMapUpdate = LiveMapUpdates; export type LiveObjectUpdate = LiveObjectUpdates; export type LiveListUpdate = LiveListUpdates; +export type LiveTextUpdate = LiveTextUpdates; + +export type Via = "edit" | "undo" | "redo"; + +/** + * type OpSource: + * Internal type for the source of an Op. Has an extra `optimistic` field + * that is not exposed publicly. + * + * type UpdateSource: + * Public type for the source of an Op. + * + * When applying an op to a CRDT, we need to know where it came from to apply + * it correctly. The three cases are: + * + * - `{ origin: "local", optimistic: true }`: applied locally (an undo, redo, or + * reconnect replay), not yet acknowledged by the server. Will be sent to the + * server and needs to be tracked for conflict resolution. + * + * - `{ origin: "local", optimistic: false }`: received from the server, but + * originated from THIS client. The server echoed it back to confirm. + * + * - `{ origin: "remote" }`: received from the server, originated from another + * client. Apply it, unless there's a pending local op for the same key (local + * ops take precedence until acknowledged). Note that a "fix Op" sent by the + * server in response to a local mutation that caused a conflict is also + * classified this way, as if another client resolved the conflict. + * + * @internal + */ +export type OpSource = + | { origin: "remote" } + | { origin: "local"; via: Via; optimistic: boolean }; + +/** + * Where a Storage update came from. + * + * Updates with `origin: "remote"` were made by another client, and reached + * this client over the network. Updates with `origin: "local"` were made by + * this client, and `via` says how: a regular edit, or a replay from the + * undo/redo history. + */ +// prettier-ignore +export type UpdateSource = + | { origin: "remote" } + | { origin: "local"; via: Via }; + +export const REMOTE = freeze({ origin: "remote" }) satisfies UpdateSource; +export const LOCAL_EDIT = freeze({ origin: "local", via: "edit" }) satisfies UpdateSource; // prettier-ignore +export const LOCAL_UNDO = freeze({ origin: "local", via: "undo" }) satisfies UpdateSource; // prettier-ignore +export const LOCAL_REDO = freeze({ origin: "local", via: "redo" }) satisfies UpdateSource; // prettier-ignore + +/** Narrows an {@link OpSource} down to what subscribers may see. */ +export function toUpdateSource(source: OpSource | UpdateSource): UpdateSource { + return source.origin === "remote" + ? source + : // Removes `optimistic` field, which is not public + { origin: "local", via: source.via }; +} /** * The payload of notifications sent (in-client) when LiveStructures change. * Messages of this kind are not originating from the network, but are 100% * in-client. + * + * Every update carries a `source`, saying where the change came from. See + * {@link UpdateSource}. */ -export type StorageUpdate = LiveMapUpdate | LiveObjectUpdate | LiveListUpdate; +export type StorageUpdate = + | LiveMapUpdate + | LiveObjectUpdate + | LiveListUpdate + | LiveTextUpdate; diff --git a/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts b/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts index 1346e21847a..42d079a0571 100644 --- a/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts +++ b/packages/liveblocks-core/src/crdts/UnacknowledgedOps.ts @@ -76,6 +76,11 @@ export class UnacknowledgedOps implements ReadonlyUnacknowledgedOps { return this.#byOpId.size; } + /** The still-unacknowledged op with the given opId, if any. */ + get(opId: string): ClientWireOp | undefined { + return this.#byOpId.get(opId); + } + /** * Mark the given Op as still unacknowledged. */ diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts index 8f7c2b15ca6..52644a6c09e 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts @@ -9,7 +9,6 @@ import { describe, expect, test, vi } from "vitest"; import { enterAndConnect, enterConnectAndGetStorage, initRoom } from "../../__tests__/_devserver"; // prettier-ignore import { createStorageFileId } from "../../lib/createIds"; import { LiveFile } from "../LiveFile"; -import type { LiveObject } from "../LiveObject"; const CONTENTS = "hello world"; @@ -19,6 +18,10 @@ type ServerStorage = { data: Record; }; +type Storage = { + file?: LiveFile; +}; + /** * Read a room's Storage straight from the server, bypassing the client's own * (optimistic) copy. @@ -50,10 +53,10 @@ describe("LiveFile", () => { test("an uploaded file can be referenced from Storage and read back", async () => { const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); - const { room, storage } = await enterConnectAndGetStorage(roomId); + const { room, storage } = await enterConnectAndGetStorage(roomId); const liveFile = await room.uploadFile(makeFile()); - (storage.root as LiveObject>).set("file", liveFile); + storage.root.set("file", liveFile); await vi.waitFor(() => expect(storage.root.toJSON()).toEqual({ @@ -69,10 +72,10 @@ describe("LiveFile", () => { test("getFileUrl returns a URL the bytes can actually be fetched from", async () => { const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); - const { room, storage } = await enterConnectAndGetStorage(roomId); + const { room, storage } = await enterConnectAndGetStorage(roomId); const liveFile = await room.uploadFile(makeFile()); - (storage.root as LiveObject>).set("file", liveFile); + storage.root.set("file", liveFile); await vi.waitFor(() => expect(storage.root.toJSON()).toHaveProperty("file") ); @@ -87,13 +90,13 @@ describe("LiveFile", () => { test("the server's size wins over whatever the client claims", async () => { const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); - const { room, storage } = await enterConnectAndGetStorage(roomId); + const { room, storage } = await enterConnectAndGetStorage(roomId); const uploaded = await room.uploadFile(makeFile()); // Same file id, but lying about its size const liar = new LiveFile({ ...uploaded.data, size: 1 }); - (storage.root as LiveObject>).set("file", liar); + storage.root.set("file", liar); // The claim is corrected where it counts. Note this asserts the *server's* // view: the lying client keeps its own optimistic value locally, since the diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts index 6ee2b0ba7db..e5cde891149 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts @@ -807,6 +807,7 @@ describe("LiveList", () => { { index: 1, item: "b", type: "insert" }, { index: 2, item: "c", type: "insert" }, ], + source: { origin: "local", via: "edit" }, }, ]); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts index 4acb79aac7f..278afe7ea70 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts @@ -598,6 +598,7 @@ describe("LiveList edge cases", () => { type: "LiveList", node: listItems, updates: [{ index: 1, item: "b", type: "insert" }], + source: { origin: "remote" }, }, ]); expect(rootDeepCallback).toHaveBeenCalledWith([ @@ -605,6 +606,7 @@ describe("LiveList edge cases", () => { type: "LiveList", node: listItems, updates: [{ index: 2, item: "c", type: "insert" }], + source: { origin: "local", via: "edit" }, }, ]); expect(listCallback).toHaveBeenCalledTimes(2); @@ -673,6 +675,7 @@ describe("LiveList edge cases", () => { type: "LiveList", node: listItems, updates: [{ index: 0, previousIndex: 1, item: "b", type: "move" }], + source: { origin: "remote" }, }, ]); @@ -733,6 +736,7 @@ describe("LiveList edge cases", () => { type: "LiveList", node: listItems, updates: [{ index: 1, type: "delete", deletedItem: "b" }], + source: { origin: "remote" }, }, ]); @@ -757,13 +761,14 @@ describe("LiveList edge cases", () => { const items = root.get("items"); const secondItem = items.get(1); - const applyResult = items._detachChild(secondItem!); + const applyResult = items._detachChild(secondItem!, { origin: "remote" }); expect(applyResult).toEqual({ modified: { type: "LiveList", node: items, updates: [{ index: 1, type: "delete", deletedItem: secondItem }], + source: { origin: "remote" }, }, reverse: [ { @@ -845,6 +850,7 @@ describe("LiveList edge cases", () => { node: items, type: "LiveList", updates: [{ type: "set", index: 0, item: "B" }], + source: { origin: "remote" }, }, ]); }); @@ -925,6 +931,7 @@ describe("LiveList edge cases", () => { node: items, type: "LiveList", updates: [{ type: "insert", index: 0, item: "B" }], + source: { origin: "remote" }, }, ]); }); @@ -971,6 +978,7 @@ describe("LiveList edge cases", () => { node: items, type: "LiveList", updates: [{ type: "insert", index: 0, item: "1" }], + source: { origin: "remote" }, }, ]); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts index 6ee3d004e1b..a711d45769a 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts @@ -637,6 +637,7 @@ describe("LiveMap", () => { type: "LiveObject", node: mapElement, updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts index a61189a94b2..a727ed89e06 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts @@ -35,13 +35,14 @@ describe("LiveMap edge cases", () => { const map = root.get("map"); const secondItem = map.get("el2"); - const applyResult = map._detachChild(secondItem!); + const applyResult = map._detachChild(secondItem!, { origin: "remote" }); expect(applyResult).toEqual({ modified: { node: map, type: "LiveMap", updates: { el2: { type: "delete", deletedItem: secondItem } }, + source: { origin: "remote" }, }, reverse: [ { diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts index d1ae96ff1e0..dc072be5cf2 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts @@ -767,6 +767,7 @@ describe("LiveObject", () => { type: "LiveObject", node: root.get("child"), updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); expect(callback).toHaveBeenCalledWith([ @@ -774,6 +775,7 @@ describe("LiveObject", () => { type: "LiveObject", node: root.get("child").get("subchild"), updates: { b: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); }); @@ -821,6 +823,7 @@ describe("LiveObject", () => { type: "LiveObject", node: rootA.get("child"), updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); expect(callback).toHaveBeenCalledWith([ @@ -828,6 +831,7 @@ describe("LiveObject", () => { type: "LiveObject", node: rootA.get("child").get("subchild"), updates: { b: { type: "update" } }, + source: { origin: "remote" }, }, ]); }); @@ -908,6 +912,7 @@ describe("LiveObject", () => { type: "LiveObject", node: rootA.get("child"), updates: { a: { type: "delete", deletedItem: -1 } }, + source: { origin: "remote" }, }, ]); expect(callback).toHaveBeenNthCalledWith(2, [ @@ -915,6 +920,7 @@ describe("LiveObject", () => { type: "LiveObject", node: rootA.get("child"), updates: { b: { type: "delete", deletedItem: -2 } }, + source: { origin: "local", via: "edit" }, }, ]); }); @@ -1042,7 +1048,15 @@ describe("LiveObject", () => { expectStorage({ a: 0 }); expect(callback).toHaveBeenCalledWith([ - { type: "LiveObject", node: root, updates: { a: { type: "update" } } }, + { + type: "LiveObject", + node: root, + updates: { a: { type: "update" } }, + source: { + origin: "local", + via: "undo", + }, + }, ]); }); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts index 647f26d722c..1d1b309ed64 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts @@ -138,13 +138,14 @@ describe("LiveObject edge cases", () => { const obj = root.get("obj"); const secondItem = obj.get("b"); - const applyResult = obj._detachChild(secondItem); + const applyResult = obj._detachChild(secondItem, { origin: "remote" }); expect(applyResult).toEqual({ modified: { node: obj, type: "LiveObject", updates: { b: { type: "delete", deletedItem: secondItem } }, + source: { origin: "remote" }, }, reverse: [ { diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.concurrency.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.concurrency.test.ts new file mode 100644 index 00000000000..15f90807978 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.concurrency.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, test } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../__tests__/_MockWebSocketServer.setup"; +import { nn } from "../../lib/assert"; +import type { UpdateTextOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import type { StorageNode } from "../../protocol/StorageNode"; +import { CrdtType } from "../../protocol/StorageNode"; +import { createManagedPool } from "../AbstractCrdt"; +import { LiveText } from "../LiveText"; +import { + applyTextOperationsToSegments, + transformTextOperations, +} from "../liveTextOps"; + +const initialNodes: StorageNode[] = [ + createSerializedRoot(), + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 0, + }, + ] as const, +]; + +describe("LiveText concurrency", () => { + test("local client rebases remote insert over pending local insert", async () => { + const { root, applyRemoteOperations } = await prepareIsolatedStorageTest<{ + text: LiveText; + }>(initialNodes, 0); + + const text = root.get("text"); + text.insert(0, "A"); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "B" }], + }, + ]); + + // The remote insert was accepted by the server first, so on a same-index + // tie it stays left of our still-pending local insert. This matches the + // outcome on the server (and on every other client). + expect(text.toString()).toBe("BAHello"); + expect(text.toJSON()).toEqual([["BAHello"]]); + }); + + test("local client rebases remote delete over pending local insert", async () => { + const { root, applyRemoteOperations } = await prepareIsolatedStorageTest<{ + text: LiveText; + }>(initialNodes, 0); + + const text = root.get("text"); + text.insert(0, "Hi"); + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "delete", index: 0, length: 2 }], + }, + ]); + + expect(text.toString()).toBe("Hillo"); + }); + + test("transform and apply converge for overlapping delete operations", () => { + const segments = [{ text: "Hello" }]; + const accepted = [{ type: "delete" as const, index: 0, length: 2 }]; + const transformed = transformTextOperations( + [{ type: "delete", index: 0, length: 2 }], + accepted, + "after" + ); + + expect( + applyTextOperationsToSegments( + applyTextOperationsToSegments(segments, accepted), + transformed + ) + ).toEqual(applyTextOperationsToSegments(segments, accepted)); + }); + + test("transform shifts format ranges over accepted inserts", () => { + expect( + transformTextOperations( + [{ type: "format", index: 1, length: 2, attributes: { bold: true } }], + [{ type: "insert", index: 0, text: "A" }], + "after" + ) + ).toEqual([ + { type: "format", index: 2, length: 2, attributes: { bold: true } }, + ]); + }); +}); + +describe("LiveText acknowledgement", () => { + test("undo of an acknowledged insert emits current-version operations", () => { + let insertOpId = ""; + let undoOps: UpdateTextOp[] = []; + const pool = createManagedPool({ + getCurrentConnectionId: () => 0, + onDispatch: (ops, reverse) => { + insertOpId = ops[0]?.opId ?? insertOpId; + undoOps = reverse as UpdateTextOp[]; + }, + }); + const text = new LiveText("Hello"); + text._attach("0:1", pool); + + text.insert(5, " world"); + expect(text.toString()).toBe("Hello world"); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: insertOpId, + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 5, text: " world" }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + + const undoOp = undoOps[0]; + if (undoOp === undefined) { + throw new Error("Expected undo operation"); + } + + const outgoingUndoOp = { ...undoOp, opId: "undo" }; + text._apply(outgoingUndoOp, { + origin: "local", + via: "edit", + optimistic: true, + }); + + expect(outgoingUndoOp).toMatchObject({ + baseVersion: 1, + ops: [{ type: "delete", index: 5, length: 6 }], + }); + expect(text.toString()).toBe("Hello"); + }); + + test("acknowledgement preserves state after concurrent remote edits", () => { + let acknowledgedOpId = ""; + const pool = createManagedPool({ + getCurrentConnectionId: () => 0, + onDispatch: (ops) => { + acknowledgedOpId = ops[0]?.opId ?? ""; + }, + }); + const text = new LiveText("Hello"); + text._attach("0:1", pool); + + text.insert(0, "A"); + expect(text.toString()).toBe("AHello"); + expect(acknowledgedOpId).not.toBe(""); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "B" }], + }, + { origin: "remote" } + ); + + // The remote insert was accepted first, so it wins the same-index tie. + expect(text.toString()).toBe("BAHello"); + + // The server acknowledges our op with its authoritative (rebased) form: + // our insert was shifted right over the accepted remote insert. + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: acknowledgedOpId, + baseVersion: 1, + version: 2, + ops: [{ type: "insert", index: 1, text: "A" }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + + expect(text.toString()).toBe("BAHello"); + expect(text.toJSON()).toEqual([["BAHello"]]); + expect(text.version).toBe(2); + }); + + test("acknowledgement applies server-rebased operations", () => { + let acknowledgedOpId = ""; + let undoOps: UpdateTextOp[] = []; + const pool = createManagedPool({ + getCurrentConnectionId: () => 0, + onDispatch: (ops, reverse) => { + acknowledgedOpId = ops[0]?.opId ?? ""; + undoOps = reverse as UpdateTextOp[]; + }, + }); + const text = new LiveText("Hello"); + text._attach("0:1", pool); + + text.delete(0, 2); + expect(text.toString()).toBe("llo"); + expect(acknowledgedOpId).not.toBe(""); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "A" }], + }, + { origin: "remote" } + ); + + expect(text.toString()).toBe("Allo"); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: acknowledgedOpId, + baseVersion: 1, + version: 2, + ops: [{ type: "delete", index: 1, length: 2 }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + + expect(text.toString()).toBe("Allo"); + expect(text.toJSON()).toEqual([["Allo"]]); + + const undoOp = undoOps[0]; + if (undoOp === undefined) { + throw new Error("Expected undo operation"); + } + + const outgoingUndoOp = { ...undoOp, opId: "undo" }; + text._apply(outgoingUndoOp, { + origin: "local", + via: "edit", + optimistic: true, + }); + + expect(outgoingUndoOp).toMatchObject({ + baseVersion: 2, + ops: [{ type: "insert", index: 1, text: "He" }], + }); + expect(text.toString()).toBe("AHello"); + }); + + test("queues local edits behind the in-flight op and flushes them on ack", () => { + const dispatched: UpdateTextOp[] = []; + const pool = createManagedPool({ + getCurrentConnectionId: () => 0, + onDispatch: (ops) => { + for (const op of ops) { + if (op.type === OpCode.UPDATE_TEXT) { + dispatched.push(op); + } + } + }, + }); + const text = new LiveText("Hello"); + text._attach("0:1", pool); + + text.insert(0, "A"); + text.insert(6, "!"); + expect(text.toString()).toBe("AHello!"); + + // Only the first edit goes on the wire; the second is queued behind it + // (one in-flight op at a time keeps wire ops in server coordinates). + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + baseVersion: 0, + ops: [{ type: "insert", index: 0, text: "A" }], + }); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: nn(dispatched[0]?.opId), + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "A" }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + + expect(text.toString()).toBe("AHello!"); + expect(text.version).toBe(1); + + // The ack flushed the queued edit as the next in-flight op. + expect(dispatched).toHaveLength(2); + expect(dispatched[1]).toMatchObject({ + baseVersion: 1, + ops: [{ type: "insert", index: 6, text: "!" }], + }); + + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: nn(dispatched[1]?.opId), + baseVersion: 1, + version: 2, + ops: [{ type: "insert", index: 6, text: "!" }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + + expect(text.toString()).toBe("AHello!"); + expect(text.version).toBe(2); + }); + + test("batched queued LiveText edit clears the redo stack", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + text: LiveText; + }>(initialNodes, 0); + + const text = root.get("text"); + + // First insert sends UPDATE_TEXT (in-flight). + room.batch(() => { + text.insert(5, "!"); + }); + expect(text.toString()).toBe("Hello!"); + + room.history.undo(); + expect(text.toString()).toBe("Hello"); + expect(room.history.canRedo()).toBe(true); + + room.history.pause(); + // Second insert while the first UPDATE_TEXT is still in-flight queues + // with empty ops + clearRedoStack: true inside the batch. + room.batch(() => { + text.insert(5, "?"); + }); + expect(text.toString()).toBe("Hello?"); + expect(room.history.canRedo()).toBe(false); + + room.history.resume(); + expect(room.history.canUndo()).toBe(true); + + room.history.undo(); + expect(text.toString()).toBe("Hello"); + + room.history.redo(); + expect(text.toString()).toBe("Hello?"); + expect(room.history.canRedo()).toBe(false); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.convergence.fuzz.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.convergence.fuzz.test.ts new file mode 100644 index 00000000000..0b9464515d5 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.convergence.fuzz.test.ts @@ -0,0 +1,849 @@ +import * as fc from "fast-check"; +import { describe, expect, test } from "vitest"; + +import type { JsonObject } from "../../lib/Json"; +import type { + LiveTextData, + LiveTextSegment, + TextOperation, + UpdateTextOp, +} from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import { createManagedPool } from "../AbstractCrdt"; +import { LiveText } from "../LiveText"; +import { + applyLiveTextOperations, + dataToSegments, + segmentsToData, + textLength, + transformTextOperations, + transformTextOperationsX, +} from "../liveTextOps"; + +// ----------------------------------------------------------------------------- +// Arbitraries +// ----------------------------------------------------------------------------- + +/** + * Includes characters wider than one UTF-16 code unit, so that generated + * indices regularly land inside a character rather than between two. + */ +const ALPHABET = [ + "a", + "b", + "c", + "d", + "e", + "f", + "😀", // surrogate pair: two code units + "é", // "e" + combining acute accent: two code points +]; + +/** Short runs of text: the bread and butter of character-level editing. */ +const shortTextArb = fc + .array(fc.integer({ min: 0, max: ALPHABET.length - 1 }), { + minLength: 1, + maxLength: 4, + }) + .map((indexes) => indexes.map((i) => ALPHABET[i]).join("")); + +/** + * Bulk text, built by repeating a short run, so that generating (and + * shrinking) multi-kilobyte documents stays cheap. + */ +function bulkTextArb( + minRepeats: number, + maxRepeats: number +): fc.Arbitrary { + return fc + .tuple(shortTextArb, fc.integer({ min: minRepeats, max: maxRepeats })) + .map(([chunk, repeats]) => chunk.repeat(repeats)); +} + +/** Tens of characters. */ +const mediumTextArb = bulkTextArb(2, 20); + +/** Hundreds to a few thousand characters. */ +const largeTextArb = bulkTextArb(200, 1000); + +const keyArb = fc.oneof( + { withCrossShrink: true }, + fc.constant("bold"), + fc.constant("italic"), + fc.constant("color"), + fc.string() +); + +const jsonScalarArb = fc.oneof( + { withCrossShrink: true }, + fc.boolean(), + fc.nat(), + fc.string(), + fc.constant(null) +); + +const attributesArb = fc.dictionary(keyArb, jsonScalarArb); + +/** + * Positions and spans are generated as fractions of the document instead of + * absolute character counts, so one seed means "a quarter of the way in" + * whether the document holds 10 characters or 10 kilobytes. Keeping the seeds + * independent of the document also keeps shrinking effective. + */ +const fractionArb = fc.double({ min: 0, max: 1, noNaN: true }); + +type PosSeed = + | { kind: "fraction"; of: number } + | { kind: "index"; index: number }; + +type SpanSeed = + | { kind: "chars"; n: number } + | { kind: "fraction"; of: number } + | { kind: "toEnd" }; + +/** + * Generated seeds always use fractions. The `index` and `chars` variants exist + * so hand-written scenarios below can name an exact spot in a known document. + */ +const atIndex = (index: number): PosSeed => ({ kind: "index", index }); +const spanChars = (n: number): SpanSeed => ({ kind: "chars", n }); + +const posSeedArb: fc.Arbitrary = fc.record({ + kind: fc.constant("fraction" as const), + of: fractionArb, +}); + +const spanSeedArb: fc.Arbitrary = fc.oneof( + { + weight: 6, + arbitrary: fc.record({ + kind: fc.constant("chars" as const), + n: fc.integer({ min: 1, max: 8 }), + }), + }, + { + weight: 3, + arbitrary: fc.record({ + kind: fc.constant("fraction" as const), + of: fractionArb, + }), + }, + { weight: 1, arbitrary: fc.record({ kind: fc.constant("toEnd" as const) }) } +); + +/** Seeds that get concretized against the current document length. */ +type EditSeed = + | { type: "insert"; at: PosSeed; text: string; attrs: JsonObject | undefined } + | { type: "delete"; at: PosSeed; span: SpanSeed } + | { type: "format"; at: PosSeed; span: SpanSeed; attrs: JsonObject }; + +function editSeedArbOf(textArb: fc.Arbitrary): fc.Arbitrary { + return fc.oneof( + fc.record({ + type: fc.constant("insert" as const), + at: posSeedArb, + text: textArb, + attrs: fc.option(attributesArb, { nil: undefined }), + }), + fc.record({ + type: fc.constant("delete" as const), + at: posSeedArb, + span: spanSeedArb, + }), + fc.record({ + type: fc.constant("format" as const), + at: posSeedArb, + span: spanSeedArb, + attrs: attributesArb, + }) + ); +} + +/** Resolves a position seed onto an index in [0, max]. */ +function indexAt(pos: PosSeed, max: number): number { + return pos.kind === "index" + ? Math.min(pos.index, max) + : Math.min(max, Math.floor(pos.of * (max + 1))); +} + +/** Resolves a span seed against the characters left after the index. */ +function spanLength(span: SpanSeed, remaining: number): number { + switch (span.kind) { + case "chars": + return Math.min(span.n, remaining); + case "fraction": + return Math.min(remaining, Math.max(1, Math.ceil(span.of * remaining))); + case "toEnd": + return remaining; + } +} + +function concretize(seed: EditSeed, length: number): TextOperation | undefined { + if (seed.type === "insert") { + return { + type: "insert", + index: indexAt(seed.at, length), + text: seed.text, + ...(seed.attrs !== undefined ? { attributes: seed.attrs } : {}), + }; + } + if (length === 0) { + return undefined; + } + const index = indexAt(seed.at, length - 1); + const len = spanLength(seed.span, length - index); + if (len <= 0) { + return undefined; + } + if (seed.type === "delete") { + return { type: "delete", index, length: len }; + } + return { type: "format", index, length: len, attributes: seed.attrs }; +} + +/** Generate a sequential op list valid against a doc of the given length. */ +function concretizeSequence( + seeds: readonly EditSeed[], + initialLength: number +): TextOperation[] { + const ops: TextOperation[] = []; + let length = initialLength; + for (const seed of seeds) { + const op = concretize(seed, length); + if (op === undefined) { + continue; + } + ops.push(op); + if (op.type === "insert") { + length += op.text.length; + } else if (op.type === "delete") { + length -= op.length; + } + } + return ops; +} + +function docArbOf( + textArb: fc.Arbitrary, + minSegments: number, + maxSegments: number +): fc.Arbitrary { + return fc + .array( + fc.record({ + text: textArb, + attrs: fc.option(attributesArb, { nil: undefined }), + }), + { minLength: minSegments, maxLength: maxSegments } + ) + .map((segments) => + segments.map( + ({ text, attrs }): LiveTextSegment => + attrs === undefined ? [text] : [text, attrs] + ) + ); +} + +const smallDocArb = docArbOf(shortTextArb, 0, 3); // a dozen characters +const mediumDocArb = docArbOf(mediumTextArb, 1, 20); // up to ~1.5 KB +const largeDocArb = docArbOf(largeTextArb, 1, 4); // multiple KBs + +/** + * The everyday mix. Small documents dominate: they are fast to run and shrink + * to failures a human can read. + */ +const docArb = fc.oneof( + { weight: 8, arbitrary: smallDocArb }, + { weight: 3, arbitrary: mediumDocArb } +); + +/** The heavyweight mix, for the properties that run few but large rounds. */ +const bulkDocArb = fc.oneof( + { weight: 1, arbitrary: mediumDocArb }, + { weight: 3, arbitrary: largeDocArb } +); + +/** Edits sized for {@link docArb}: mostly typing, the occasional paste. */ +const editSeedArb = editSeedArbOf( + fc.oneof( + { weight: 9, arbitrary: shortTextArb }, + { weight: 1, arbitrary: mediumTextArb } + ) +); + +/** Edits sized for {@link bulkDocArb}, including multi-KB pastes. */ +const bulkEditSeedArb = editSeedArbOf( + fc.oneof( + { weight: 6, arbitrary: shortTextArb }, + { weight: 3, arbitrary: mediumTextArb }, + { weight: 1, arbitrary: largeTextArb } + ) +); + +// ----------------------------------------------------------------------------- +// TP1: transform correctness for concurrent op sequences +// ----------------------------------------------------------------------------- + +describe("transformTextOperations TP1 property", () => { + test("doc ⊕ A ⊕ B' === doc ⊕ B ⊕ A' for concurrent sequences", () => { + fc.assert( + fc.property( + docArb, + fc.array(editSeedArb, { minLength: 1, maxLength: 3 }), + fc.array(editSeedArb, { minLength: 1, maxLength: 3 }), + fc.constantFrom("before" as const, "after" as const), + + (doc, seedsA, seedsB, order) => { + const length = textLength(dataToSegments(doc)); + const a = concretizeSequence(seedsA, length); + const b = concretizeSequence(seedsB, length); + + const [a1, b1] = transformTextOperationsX(a, b, order); + + // Path 1: apply A, then B-transformed-over-A + const path1 = applyLiveTextOperations( + applyLiveTextOperations(doc, a), + b1 + ); + // Path 2: apply B, then A-transformed-over-B + const path2 = applyLiveTextOperations( + applyLiveTextOperations(doc, b), + a1 + ); + + expect(path1).toEqual(path2); + } + ), + { numRuns: 2000 } + ); + }); + + test("TP1 holds on multi-kilobyte documents", () => { + fc.assert( + fc.property( + bulkDocArb, + fc.array(bulkEditSeedArb, { minLength: 1, maxLength: 6 }), + fc.array(bulkEditSeedArb, { minLength: 1, maxLength: 6 }), + fc.constantFrom("before" as const, "after" as const), + + (doc, seedsA, seedsB, order) => { + const length = textLength(dataToSegments(doc)); + const a = concretizeSequence(seedsA, length); + const b = concretizeSequence(seedsB, length); + + const [a1, b1] = transformTextOperationsX(a, b, order); + + const path1 = applyLiveTextOperations( + applyLiveTextOperations(doc, a), + b1 + ); + const path2 = applyLiveTextOperations( + applyLiveTextOperations(doc, b), + a1 + ); + + expect(path1).toEqual(path2); + } + ), + { numRuns: 300 } + ); + }); + + test("delete spanning a concurrent insert preserves the inserted text", () => { + // doc "abcdef": A deletes [1, 5), B inserts "XY" at 3 (inside the range) + const a: TextOperation[] = [{ type: "delete", index: 1, length: 4 }]; + const b: TextOperation[] = [{ type: "insert", index: 3, text: "XY" }]; + + const [a1, b1] = transformTextOperationsX(a, b, "after"); + + const path1 = applyLiveTextOperations( + applyLiveTextOperations([["abcdef"]], a), + b1 + ); + const path2 = applyLiveTextOperations( + applyLiveTextOperations([["abcdef"]], b), + a1 + ); + + expect(path1).toEqual(path2); + // The concurrent insert must survive the delete. + expect(path1).toEqual([["aXYf"]]); + }); + + test("same-index inserts: earlier op stays left under both orders", () => { + const a: TextOperation[] = [{ type: "insert", index: 0, text: "A" }]; + const b: TextOperation[] = [{ type: "insert", index: 0, text: "B" }]; + + // A ordered before B + const aBeforeB_b1 = transformTextOperations(b, a, "after"); + const aBeforeB_a1 = transformTextOperations(a, b, "before"); + + const path1 = applyLiveTextOperations( + applyLiveTextOperations([["x"]], a), + aBeforeB_b1 + ); + const path2 = applyLiveTextOperations( + applyLiveTextOperations([["x"]], b), + aBeforeB_a1 + ); + + expect(path1).toEqual([["ABx"]]); + expect(path2).toEqual([["ABx"]]); + }); + + test("overlapping concurrent formats: later op wins conflicting keys", () => { + const a: TextOperation[] = [ + { type: "format", index: 0, length: 4, attributes: { bold: true } }, + ]; + const b: TextOperation[] = [ + { type: "format", index: 2, length: 4, attributes: { bold: null } }, + ]; + + // A ordered before B in the final timeline + const [a1, b1] = transformTextOperationsX(a, b, "before"); + + const path1 = applyLiveTextOperations( + applyLiveTextOperations([["abcdef"]], a), + b1 + ); + const path2 = applyLiveTextOperations( + applyLiveTextOperations([["abcdef"]], b), + a1 + ); + + expect(path1).toEqual(path2); + expect(path1).toEqual([["ab", { bold: true }], ["cdef"]]); + }); + + test("regression: attribute names off Object.prototype are not treated as conflicts", () => { + // "toString" is an inherited key on any plain object, so a naive `key in + // over.attributes` check sees a conflict with an op that never set it. + const a: TextOperation[] = [ + { type: "format", index: 0, length: 1, attributes: {} }, + ]; + const b: TextOperation[] = [ + { type: "format", index: 0, length: 1, attributes: { toString: false } }, + ]; + + const [a1, b1] = transformTextOperationsX(a, b, "after"); + + const path1 = applyLiveTextOperations( + applyLiveTextOperations([["a"]], a), + b1 + ); + const path2 = applyLiveTextOperations( + applyLiveTextOperations([["a"]], b), + a1 + ); + + expect(path1).toEqual(path2); + expect(path1).toEqual([["a", { toString: false }]]); + }); +}); + +// ----------------------------------------------------------------------------- +// Multi-client convergence simulation +// ----------------------------------------------------------------------------- + +type ServerHistoryEntry = { + version: number; + opId: string; + ops: TextOperation[]; +}; + +type ServerResult = { + /** Echo back to the sender (with opId), or undefined if ignored. */ + ack?: UpdateTextOp; + /** Forward to all other clients (no opId), or undefined. */ + forward?: UpdateTextOp; +}; + +/** + * Minimal re-implementation of the server-side `applyUpdateTextOp` semantics + * (see liveblocks-server Storage.ts), using the same shared transform. + */ +class MiniServer { + data: LiveTextData; + version = 0; + history: ServerHistoryEntry[] = []; + + constructor(data: LiveTextData) { + this.data = data; + } + + receive(op: UpdateTextOp & { opId: string }): ServerResult { + const duplicate = this.history.find((entry) => entry.opId === op.opId); + if (duplicate !== undefined) { + return { + ack: { + ...op, + baseVersion: duplicate.version - 1, + version: duplicate.version, + ops: [...duplicate.ops], + }, + }; + } + + if (op.ops.length === 0) { + // Empty update: pure ack vehicle, ignored. + return {}; + } + + if (op.baseVersion > this.version) { + throw new Error("Client base version ahead of server"); + } + + const acceptedOps = this.history + .filter((entry) => entry.version > op.baseVersion) + .flatMap((entry) => entry.ops); + const ops = + acceptedOps.length > 0 + ? transformTextOperations(op.ops, acceptedOps, "after") + : [...op.ops]; + + const baseVersion = this.version; + const version = this.version + 1; + this.data = applyLiveTextOperations(this.data, ops); + this.version = version; + this.history.push({ version, opId: op.opId, ops }); + + return { + ack: { ...op, baseVersion, version, ops: [...ops] }, + forward: { + type: OpCode.UPDATE_TEXT, + id: op.id, + baseVersion, + version, + ops: [...ops], + }, + }; + } +} + +type SimEvent = + | { kind: "edit"; client: number; seed: EditSeed } + | { kind: "undo"; client: number } + | { kind: "toServer"; client: number } + | { kind: "toClient"; client: number }; + +function simEventArb( + numClients: number, + seedArb: fc.Arbitrary = editSeedArb +): fc.Arbitrary { + const client = fc.nat(numClients - 1); + return fc.oneof( + { + weight: 4, + arbitrary: fc.record({ + kind: fc.constant("edit" as const), + client, + seed: seedArb, + }), + }, + { + weight: 1, + arbitrary: fc.record({ kind: fc.constant("undo" as const), client }), + }, + { + weight: 3, + arbitrary: fc.record({ kind: fc.constant("toServer" as const), client }), + }, + { + weight: 3, + arbitrary: fc.record({ kind: fc.constant("toClient" as const), client }), + } + ); +} + +class SimClient { + text: LiveText; + outbox: (UpdateTextOp & { opId: string })[] = []; + inbox: UpdateTextOp[] = []; + undoStack: UpdateTextOp[][] = []; + #opClock = 0; + readonly id: number; + + constructor(id: number, data: LiveTextData, version: number) { + this.id = id; + this.text = new LiveText(data, version); + const pool = createManagedPool({ + getCurrentConnectionId: () => id, + onDispatch: (ops, reverse) => { + for (const op of ops) { + if (op.type === OpCode.UPDATE_TEXT) { + this.outbox.push(op); + } + } + const reverseTextOps = reverse.filter( + (op): op is UpdateTextOp => op.type === OpCode.UPDATE_TEXT + ); + if (reverseTextOps.length > 0) { + this.undoStack.push(reverseTextOps); + } + }, + }); + this.text._attach("0:1", pool); + } + + edit(seed: EditSeed): void { + const op = concretize(seed, this.text.length); + if (op === undefined) { + return; + } + if (op.type === "insert") { + this.text.insert(op.index, op.text, op.attributes); + } else if (op.type === "delete") { + this.text.delete(op.index, op.length); + } else { + this.text.format(op.index, op.length, op.attributes); + } + } + + undo(): void { + const frame = this.undoStack.pop(); + if (frame === undefined) { + return; + } + // Mimic room.applyLocalOps(): assign opIds, apply locally, send. + for (const op of frame) { + const wireOp = { ...op, opId: `${this.id}:u${this.#opClock++}` }; + this.text._apply(wireOp, { + origin: "local", + via: "edit", + optimistic: true, + }); + this.outbox.push(wireOp); + } + } + + receive(): void { + const message = this.inbox.shift(); + if (message === undefined) { + return; + } + // Acks carry our own opId; forwards from other clients don't. + this.text._apply( + message, + message.opId !== undefined + ? { origin: "local", via: "edit", optimistic: false } + : { origin: "remote" } + ); + } +} + +function runSimulation( + initialData: LiveTextData, + events: readonly SimEvent[], + numClients: number +): { server: MiniServer; clients: SimClient[] } { + const server = new MiniServer(initialData); + const clients = Array.from( + { length: numClients }, + (_, i) => new SimClient(i, initialData, 0) + ); + + const pumpToServer = (client: SimClient) => { + const op = client.outbox.shift(); + if (op === undefined) { + return; + } + const { ack, forward } = server.receive(op); + if (ack !== undefined) { + client.inbox.push(ack); + } + if (forward !== undefined) { + for (const other of clients) { + if (other !== client) { + other.inbox.push(forward); + } + } + } + }; + + for (const event of events) { + const client = + clients[ + event.kind === "edit" || + event.kind === "undo" || + event.kind === "toServer" || + event.kind === "toClient" + ? event.client + : 0 + ]; + switch (event.kind) { + case "edit": + client.edit(event.seed); + break; + case "undo": + client.undo(); + break; + case "toServer": + pumpToServer(client); + break; + case "toClient": + client.receive(); + break; + } + } + + // Drain: deliver everything until the system is quiescent. Acks can cause + // clients to flush queued ops, so keep pumping. + for (let i = 0; i < 10_000; i++) { + const busy = clients.some((c) => c.outbox.length > 0 || c.inbox.length > 0); + if (!busy) { + break; + } + for (const client of clients) { + while (client.outbox.length > 0) { + pumpToServer(client); + } + } + for (const client of clients) { + while (client.inbox.length > 0) { + client.receive(); + } + } + } + + for (const client of clients) { + if (client.outbox.length > 0 || client.inbox.length > 0) { + throw new Error("Simulation did not quiesce"); + } + } + + return { server, clients }; +} + +describe("LiveText multi-client convergence (fuzz)", () => { + test("all clients converge to the server document", () => { + fc.assert( + fc.property( + docArb, + fc.array(simEventArb(3), { minLength: 1, maxLength: 60 }), + (doc, events) => { + const { server, clients } = runSimulation(doc, events, 3); + + const serverText = dataToSegments(server.data) + .map((s) => s.text) + .join(""); + + for (const client of clients) { + expect(client.text.toString()).toBe(serverText); + expect(client.text.toJSON()).toEqual( + segmentsToData(dataToSegments(server.data)) + ); + expect(client.text.version).toBe(server.version); + } + } + ), + { numRuns: 300 } + ); + }); + + test("all clients converge on multi-kilobyte documents", () => { + fc.assert( + fc.property( + bulkDocArb, + fc.array(simEventArb(3, bulkEditSeedArb), { + minLength: 1, + maxLength: 30, + }), + (doc, events) => { + const { server, clients } = runSimulation(doc, events, 3); + + const serverText = dataToSegments(server.data) + .map((s) => s.text) + .join(""); + + for (const client of clients) { + expect(client.text.toString()).toBe(serverText); + expect(client.text.version).toBe(server.version); + } + } + ), + { numRuns: 150 } + ); + }); + + test("regression: same-index concurrent inserts converge", () => { + const events: SimEvent[] = [ + { + kind: "edit", + client: 0, + seed: { type: "insert", at: atIndex(0), text: "aa", attrs: undefined }, + }, + { + kind: "edit", + client: 1, + seed: { type: "insert", at: atIndex(0), text: "bb", attrs: undefined }, + }, + // Client 0's op reaches the server first + { kind: "toServer", client: 0 }, + { kind: "toServer", client: 1 }, + ]; + + const { server, clients } = runSimulation([["x"]], events, 2); + const serverText = dataToSegments(server.data) + .map((s) => s.text) + .join(""); + + expect(serverText).toBe("aabbx"); + for (const client of clients) { + expect(client.text.toString()).toBe(serverText); + } + }); + + test("regression: delete spanning a concurrent insert keeps the insert", () => { + const events: SimEvent[] = [ + // Client 0 deletes "bcde" out of "abcdef" + { + kind: "edit", + client: 0, + seed: { type: "delete", at: atIndex(1), span: spanChars(4) }, + }, + // Client 1 types "ZZ" in the middle of that range + { + kind: "edit", + client: 1, + seed: { type: "insert", at: atIndex(3), text: "zz", attrs: undefined }, + }, + { kind: "toServer", client: 1 }, + { kind: "toServer", client: 0 }, + ]; + + const { server, clients } = runSimulation([["abcdef"]], events, 2); + const serverText = dataToSegments(server.data) + .map((s) => s.text) + .join(""); + + // The concurrently inserted text must survive + expect(serverText).toContain("zz"); + for (const client of clients) { + expect(client.text.toString()).toBe(serverText); + } + }); + + test("regression: sequential local edits are not double-transformed by the server", () => { + const events: SimEvent[] = [ + // Client 0, on "AB": insert "x" at 1 ("AxB"), then delete "B" (now at index 2) + { + kind: "edit", + client: 0, + seed: { type: "insert", at: atIndex(1), text: "x", attrs: undefined }, + }, + { + kind: "edit", + client: 0, + seed: { type: "delete", at: atIndex(2), span: spanChars(1) }, + }, + ]; + + const { server, clients } = runSimulation([["AB"]], events, 1); + const serverText = dataToSegments(server.data) + .map((s) => s.text) + .join(""); + + expect(serverText).toBe("Ax"); + expect(clients[0].text.toString()).toBe("Ax"); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.encode.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.encode.test.ts new file mode 100644 index 00000000000..a3966b6d51a --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.encode.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, test } from "vitest"; + +import { kInternal } from "../../internal"; +import { nn } from "../../lib/assert"; +import type { UpdateTextOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import { createManagedPool } from "../AbstractCrdt"; +import { LiveText } from "../LiveText"; + +/** + * Helper: build a LiveText attached to a pool that captures the wire ops it + * dispatches, so tests can fabricate matching acks via + * `_apply(_, { origin: "local", via: "edit", optimistic: false })`. + */ +function attachedLiveText(initial: string): { + text: LiveText; + dispatched: UpdateTextOp[]; +} { + const dispatched: UpdateTextOp[] = []; + const pool = createManagedPool({ + getCurrentConnectionId: () => 0, + onDispatch: (ops) => { + for (const op of ops) { + if (op.type === OpCode.UPDATE_TEXT) { + dispatched.push(op); + } + } + }, + }); + const text = new LiveText(initial); + text._attach("0:1", pool); + return { text, dispatched }; +} + +/** Build the server-side ack matching the latest dispatched local op. */ +function ackOp(dispatched: UpdateTextOp[], ackedVersion: number): UpdateTextOp { + const last = nn(dispatched.at(-1), "Expected a dispatched local op"); + return { + type: OpCode.UPDATE_TEXT, + id: last.id, + opId: nn(last.opId, "Local ops must carry an opId"), + baseVersion: last.baseVersion, + version: ackedVersion, + ops: [...last.ops], + }; +} + +// ============================================================================ +// _encodeIndex +// ============================================================================ + +describe("LiveText[kInternal].encodeIndex", () => { + test("returns the index unchanged at version 0 when there are no pending ops", () => { + const text = new LiveText("Hello"); + expect(text[kInternal].encodeIndex(3)).toBe(3); + }); + + test("clamps the index into [0, length]", () => { + const text = new LiveText("Hi"); + expect(text[kInternal].encodeIndex(-10)).toBe(0); + expect(text[kInternal].encodeIndex(999)).toBe(2); + }); + + test("inverse-maps a cursor placed after an in-flight insert", () => { + const { text } = attachedLiveText("Hello"); + text.insert(5, " world"); // in-flight: insert " world" at 5 (len 6) + expect(text.toString()).toBe("Hello world"); + + // CM cursor at offset 11 (end, just past " world") should encode to + // offset 5 in #confirmed coords (just past "Hello"). + expect(text[kInternal].encodeIndex(11)).toBe(5); + }); + + test("a cursor positioned before an in-flight insert encodes unchanged", () => { + const { text } = attachedLiveText("Hello"); + text.insert(5, " world"); + + expect(text[kInternal].encodeIndex(3)).toBe(3); + }); + + test("a cursor inside an in-flight insert collapses to the insertion point", () => { + const { text } = attachedLiveText("Hello"); + text.insert(2, "XYZ"); + // #segments = "HeXYZllo"; positions 2..5 are inside the insertion. + expect(text[kInternal].encodeIndex(2)).toBe(2); + expect(text[kInternal].encodeIndex(3)).toBe(2); + expect(text[kInternal].encodeIndex(4)).toBe(2); + expect(text[kInternal].encodeIndex(5)).toBe(2); + }); + + test("inverse-maps through both in-flight and queued ops", () => { + const { text } = attachedLiveText("Hello"); + text.insert(5, "!"); // in-flight: insert "!" at 5 + text.insert(0, "X"); // queued: insert "X" at 0 (applied on top of "Hello!") + expect(text.toString()).toBe("XHello!"); + + // CM cursor at 7 ("XHello!|") should encode to 5 in #confirmed coords. + // Undo queued first (insert "X" at 0): 7 → 6 (shift left by 1). + // Undo in-flight (insert "!" at 5): 6 → max(5, 6-1) = 5. + expect(text[kInternal].encodeIndex(7)).toBe(5); + + // CM cursor at 1 ("X|Hello!") should encode to 0 in #confirmed coords. + // Undo queued first: 1 → max(0, 1-1) = 0. Undo in-flight: 0 ≤ 5 → 0. + expect(text[kInternal].encodeIndex(1)).toBe(0); + }); + + test("does not change the reported version on the LiveText node", () => { + const { text, dispatched } = attachedLiveText("Hello"); + text.insert(5, "!"); + text._apply(ackOp(dispatched, 1), { + origin: "local", + via: "edit", + optimistic: false, + }); + + expect(text.version).toBe(1); + expect(text[kInternal].encodeIndex(6)).toBe(6); + }); +}); + +// ============================================================================ +// _decodeIndex +// ============================================================================ + +describe("LiveText[kInternal].decodeIndex", () => { + test("returns the index unchanged when fromVersion equals current and no pending", () => { + const text = new LiveText("Hello"); + expect(text[kInternal].decodeIndex(3, 0)).toBe(3); + }); + + test("returns null when fromVersion is ahead of the current version", () => { + const text = new LiveText("Hello"); + expect(text[kInternal].decodeIndex(3, 5)).toBeNull(); + }); + + test("returns null when fromVersion is older than retained accepted-ops history", () => { + const { text } = attachedLiveText("Hello"); + // Apply two remote ops to advance the version to 2. + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 5, text: "!" }], + }, + { origin: "remote" } + ); + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 1, + version: 2, + ops: [{ type: "insert", index: 6, text: "?" }], + }, + { origin: "remote" } + ); + expect(text.version).toBe(2); + // fromVersion = 5 is in the future → null (covered by the ahead branch). + expect(text[kInternal].decodeIndex(0, 5)).toBeNull(); + // fromVersion = 0 should still be reachable: oldest entry is at version 1 + // which is ≤ fromVersion + 1. + expect(text[kInternal].decodeIndex(0, 0)).not.toBeNull(); + }); + + test("clamps the result into [0, length]", () => { + const text = new LiveText("Hi"); + expect(text[kInternal].decodeIndex(-5, 0)).toBe(0); + expect(text[kInternal].decodeIndex(999, 0)).toBe(2); + }); + + test("forwards an index from an older confirmed version through accepted ops", () => { + const { text } = attachedLiveText("Hello"); + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "Z" }], + }, + { origin: "remote" } + ); + // A peer broadcast at version 0 with index 3 ("Hel|lo"). After our + // accepted insert of "Z" at 0, the same logical position is at offset 4 + // in "ZHello". + expect(text[kInternal].decodeIndex(3, 0)).toBe(4); + }); + + test("forwards an index through local pending ops on top of the cross-version pass", () => { + const { text, dispatched } = attachedLiveText("Hello"); + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "Z" }], + }, + { origin: "remote" } + ); + expect(text.toString()).toBe("ZHello"); + + text.insert(0, "Q"); // in-flight: "QZHello" + text.insert(7, "!"); // queued: "QZHello!" + expect(text.toString()).toBe("QZHello!"); + + // Peer broadcast at version 0, index 3 ("Hel|lo"). + // - Cross-version pass (accepted insert Z at 0): 3 → 4. + // - Local in-flight (insert Q at 0): 4 → 5. + // - Local queued (insert ! at 7): 5 (5 < 7, unchanged). + expect(text[kInternal].decodeIndex(3, 0)).toBe(5); + + // Acknowledge the in-flight Q insert and verify decoding behaviour is + // stable across the ack (Q moved from in-flight into #confirmed; the + // queued ! becomes the next in-flight). + text._apply(ackOp(dispatched, 2), { + origin: "local", + via: "edit", + optimistic: false, + }); + expect(text.version).toBe(2); + // Now: cross-version (versions 1 and 2): version 1 has insert Z at 0 → 3 + // becomes 4. Version 2 records the local Q ack as empty ops (own ack), so + // no shift there. Then local pending = [insert ! at 7] → 4 stays at 4. + expect(text[kInternal].decodeIndex(3, 0)).toBe(4); + }); + + test("own acks are recorded as empty ops and do not shift decoded positions", () => { + const { text, dispatched } = attachedLiveText("Hello"); + text.insert(0, "A"); + text._apply(ackOp(dispatched, 1), { + origin: "local", + via: "edit", + optimistic: false, + }); + + expect(text.version).toBe(1); + // A peer at version 0 broadcasts index 3 ("Hel|lo"). On our side, the + // local insert of "A" was acked as a server-ordered op with no remote + // ops happening — #acceptedOps records empty ops for this ack. The peer + // simply hasn't seen our edit yet, so their index is in the old + // confirmed coords; we should NOT shift it. (When the peer eventually + // receives our edit, their CM transaction will do the local mapping.) + expect(text[kInternal].decodeIndex(3, 0)).toBe(3); + }); + + test("decoding the peer's broadcast version returns the result through local pending only", () => { + const { text } = attachedLiveText("Hello"); + text.insert(2, "_"); + expect(text.toString()).toBe("He_llo"); + + // Peer at the same version 0 broadcasts index 4 ("Hell|o"). We have an + // in-flight insert of "_" at 2, which shifts everything past 2 to the + // right by one in our local view. + expect(text[kInternal].decodeIndex(4, 0)).toBe(5); + }); +}); + +// ============================================================================ +// Round-trip + end-to-end +// ============================================================================ + +describe("LiveText encode/decode pair", () => { + test("encode then decode on the same instance round-trips outside pending insertion boundaries", () => { + const { text } = attachedLiveText("Hello world"); + text.insert(5, "!"); + // CM doc is "Hello! world" (length 12). The insert occupies CM range + // [5, 6]; positions inside or exactly on either edge are subject to the + // OT assoc ambiguity (encode collapses leftward, decode shifts rightward). + // Positions strictly outside [5, 6] round-trip exactly. + for (const index of [0, 1, 4, 7, 8, 11, 12]) { + const encoded = text[kInternal].encodeIndex(index); + const decoded = text[kInternal].decodeIndex(encoded, text.version); + expect(decoded).toBe(index); + } + }); + + test("encoded position lands at the same logical location on a peer with identical state", () => { + // Two LiveTexts at the same version with no pending: any encoded index + // should decode to itself on the peer. + const sender = new LiveText("Hello world"); + const receiver = new LiveText("Hello world"); + const encoded = sender[kInternal].encodeIndex(6); + expect(receiver[kInternal].decodeIndex(encoded, sender.version)).toBe(6); + }); + + test("end-to-end convergence: sender's pending insert + receiver's pending insert", () => { + // Both clients start synced. Each types something locally before any ack. + // The sender broadcasts an encoded cursor positioned after their typing; + // the receiver decodes through their own pending state. Then both ops + // ack in some server order. After both clients have integrated each + // other's ops, the receiver's stored cursor for the sender (after + // running through subsequent local mappings) should match where the + // sender's CM cursor logically is. + + const { text: sender, dispatched: senderDispatched } = + attachedLiveText("ABCDE"); + const { text: receiver, dispatched: receiverDispatched } = + attachedLiveText("ABCDE"); + + // Sender types "1" at position 0; their cursor sits at position 4 + // (between C and D) in "1ABCDE". + sender.insert(0, "1"); + expect(sender.toString()).toBe("1ABCDE"); + const senderCursorCm = 4; + + // Receiver types "9" at position 5; their cursor sits at 0. + receiver.insert(5, "9"); + expect(receiver.toString()).toBe("ABCDE9"); + + // Sender encodes and broadcasts. + const broadcastIndex = sender[kInternal].encodeIndex(senderCursorCm); + const broadcastVersion = sender.version; + expect(broadcastIndex).toBe(3); + expect(broadcastVersion).toBe(0); + + // Receiver decodes against their current state (pending insert of "9"). + // index 3 is below 5, so the receiver's pending insert of "9" at 5 + // leaves it unchanged. + const decodedNow = receiver[kInternal].decodeIndex( + broadcastIndex, + broadcastVersion + ); + expect(decodedNow).toBe(3); + // Position 3 in "ABCDE9" is between C and D — matches the sender's + // logical position. + + // Now sync ops. Server orders sender's first (version 1), then + // receiver's (version 2, server-rebased to insert "9" at position 6 in + // the version-1 doc "1ABCDE"). + receiver._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "1" }], + }, + { origin: "remote" } + ); + expect(receiver.toString()).toBe("1ABCDE9"); + // The server delivers the receiver's own ack in server-rebased form: + // their original insert "9" at 5 was shifted to "9" at 6 after the + // server applied the sender's insert "1" at 0 first. + receiver._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + opId: nn(receiverDispatched.at(-1)?.opId), + baseVersion: 1, + version: 2, + ops: [{ type: "insert", index: 6, text: "9" }], + }, + { origin: "local", via: "edit", optimistic: false } + ); + expect(receiver.toString()).toBe("1ABCDE9"); + expect(receiver.version).toBe(2); + + // The receiver now decodes the same broadcast (still anchored at + // version 0). The decode should put the sender's cursor at position 4 + // in "1ABCDE9" — between C and D. That's the same logical place. + const decodedAfterSync = receiver[kInternal].decodeIndex( + broadcastIndex, + broadcastVersion + ); + expect(decodedAfterSync).toBe(4); + + // Sender acks their own op and applies the receiver's accepted op. + sender._apply(ackOp(senderDispatched, 1), { + origin: "local", + via: "edit", + optimistic: false, + }); + sender._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 1, + version: 2, + ops: [{ type: "insert", index: 6, text: "9" }], + }, + { origin: "remote" } + ); + expect(sender.toString()).toBe("1ABCDE9"); + expect(sender.version).toBe(2); + + // Sender re-encodes their (unchanged) CM cursor: still between C and D + // in "1ABCDE9", which is position 4. + const senderCursorAfterSync = 4; + const reBroadcastIndex = sender[kInternal].encodeIndex( + senderCursorAfterSync + ); + expect(reBroadcastIndex).toBe(4); + expect(sender.version).toBe(2); + + // Receiver decodes the fresh broadcast → position 4. + expect( + receiver[kInternal].decodeIndex(reBroadcastIndex, sender.version) + ).toBe(4); + }); + + test("peer ahead of us: decode returns null and succeeds after we catch up", () => { + // The peer is one ack ahead of us. They broadcast a cursor at their + // version. We can't rebase it yet. + const { text } = attachedLiveText("Hello"); + expect(text[kInternal].decodeIndex(3, 1)).toBeNull(); + + // Once we receive the catching-up accepted op, decode works. + text._apply( + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "Z" }], + }, + { origin: "remote" } + ); + expect(text.version).toBe(1); + expect(text[kInternal].decodeIndex(3, 1)).toBe(3); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.devserver.test.ts new file mode 100644 index 00000000000..4e84bcc3d5b --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.devserver.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from "vitest"; + +import { prepareStorageTest } from "../../__tests__/_devserver"; +import type { LiveText } from "../LiveText"; + +describe("LiveText history convergence", () => { + test("an offline update from a deleted lifetime cannot desynchronize two clients", async () => { + const { roomA, roomB, storageA, storageB } = await prepareStorageTest<{ + text: LiveText; + }>({ + liveblocksType: "LiveObject", + data: { + text: { + liveblocksType: "LiveText", + data: [["Hello"]], + }, + }, + }); + + roomB.disconnect(); + storageB.root.get("text").insert(5, "?"); + + storageA.root.delete("text"); + roomA.history.undo(); + + await vi.waitFor(() => { + expect(roomA.getStorageStatus()).toBe("synchronized"); + }); + + roomB.connect(); + await vi.waitFor(() => { + expect(roomB.getStatus()).toBe("connected"); + expect(storageB.root.toJSON()).toEqual(storageA.root.toJSON()); + expect(roomB.getStorageStatus()).toBe("synchronized"); + }); + + storageA.root.get("text").insert(5, "!"); + await vi.waitFor(() => { + expect(storageA.root.toJSON()).toEqual({ text: [["Hello!"]] }); + expect(storageB.root.toJSON()).toEqual({ text: [["Hello!"]] }); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.mockserver.test.ts new file mode 100644 index 00000000000..d9435fcdbb1 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.history.mockserver.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { MockWebSocketServer } from "../../__tests__/_MockWebSocketServer"; +import { + createSerializedRoot, + parseAsClientMsgs, + prepareIsolatedStorageTest, + replaceRemoteStorageAndReconnect, +} from "../../__tests__/_MockWebSocketServer.setup"; +import { waitUntilStatus } from "../../__tests__/_waitUtils"; +import { ClientMsgCode } from "../../protocol/ClientMsg"; +import type { ClientWireOp, CreateTextOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import type { StorageNode } from "../../protocol/StorageNode"; +import { CrdtType } from "../../protocol/StorageNode"; +import type { LiveText } from "../LiveText"; + +const OLD_TEXT_ID = "0:1"; +const initialNodes: StorageNode[] = [ + createSerializedRoot(), + [ + OLD_TEXT_ID, + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 7, + }, + ], +]; + +function getStorageOps(wss: MockWebSocketServer): ClientWireOp[] { + return wss.receivedMessagesRaw.flatMap((raw) => + parseAsClientMsgs(raw).flatMap((message) => + message.type === ClientMsgCode.UPDATE_STORAGE ? message.ops : [] + ) + ); +} + +function getCreateTextOps(wss: MockWebSocketServer): CreateTextOp[] { + return getStorageOps(wss).filter( + (op): op is ClientWireOp & CreateTextOp => op.type === OpCode.CREATE_TEXT + ); +} + +describe("LiveText history lifetimes", () => { + test("delete then undo creates a fresh text ID at version 0", async () => { + const { root, room, wss } = await prepareIsolatedStorageTest<{ + text?: LiveText; + }>(initialNodes, 1); + + root.delete("text"); + room.history.undo(); + + const createOp = getCreateTextOps(wss).at(-1); + expect(createOp).toMatchObject({ + type: OpCode.CREATE_TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 0, + }); + expect(createOp?.id).not.toBe(OLD_TEXT_ID); + + room.history.redo(); + expect(root.get("text")).toBeUndefined(); + }); + + test("every undo after a redo creates a new text lifetime", async () => { + const { root, room, wss } = await prepareIsolatedStorageTest<{ + text?: LiveText; + }>(initialNodes, 1); + + root.delete("text"); + + for (let i = 0; i < 3; i++) { + room.history.undo(); + room.history.redo(); + } + + const createOps = getCreateTextOps(wss); + expect(createOps).toHaveLength(3); + expect(createOps.every((op) => op.version === 0)).toBe(true); + expect(new Set(createOps.map((op) => op.id)).size).toBe(3); + expect(createOps.map((op) => op.id)).not.toContain(OLD_TEXT_ID); + }); + + test("a pending update for the deleted ID cannot affect the restored text", async () => { + const { root, room, wss, applyRemoteOperations } = + await prepareIsolatedStorageTest<{ text?: LiveText }>(initialNodes, 1); + + root.get("text")?.insert(5, "!"); + const pendingUpdate = getStorageOps(wss).find( + (op) => op.type === OpCode.UPDATE_TEXT && op.id === OLD_TEXT_ID + ); + if (pendingUpdate?.type !== OpCode.UPDATE_TEXT) { + throw new Error("Expected a pending UPDATE_TEXT operation"); + } + + root.delete("text"); + room.history.undo(); + + const restoredText = root.get("text"); + const restoredId = getCreateTextOps(wss).at(-1)?.id; + expect(restoredText?.toString()).toBe("Hello!"); + expect(restoredId).toBeDefined(); + expect(restoredId).not.toBe(OLD_TEXT_ID); + + applyRemoteOperations([{ ...pendingUpdate, version: 8 }]); + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: OLD_TEXT_ID, + baseVersion: 0, + version: 1, + ops: [{ type: "insert", index: 0, text: "stale" }], + }, + ]); + + expect(root.get("text")).toBe(restoredText); + expect(restoredText?.toString()).toBe("Hello!"); + }); + + test("reconnect replay preserves the restored text ID", async () => { + const { root, room, wss } = await prepareIsolatedStorageTest<{ + text?: LiveText; + }>(initialNodes, 1); + + root.delete("text"); + room.history.undo(); + const restoredCreate = getCreateTextOps(wss).at(-1); + if (restoredCreate === undefined) { + throw new Error("Expected a restored CREATE_TEXT operation"); + } + + // Detach the restored node again so replay sees a CREATE_TEXT whose ID is + // absent from the pool. Its existing opId is what distinguishes replay + // from a new restoration. + room.history.redo(); + const messagesBeforeReconnect = wss.receivedMessagesRaw.length; + + replaceRemoteStorageAndReconnect(wss, [createSerializedRoot()]); + await waitUntilStatus(room, "connected"); + + await vi.waitFor(() => { + const replayedCreate = wss.receivedMessagesRaw + .slice(messagesBeforeReconnect) + .flatMap(parseAsClientMsgs) + .flatMap((message) => + message.type === ClientMsgCode.UPDATE_STORAGE ? message.ops : [] + ) + .find((op) => op.type === OpCode.CREATE_TEXT); + + expect(replayedCreate).toMatchObject({ + id: restoredCreate.id, + opId: restoredCreate.opId, + version: 0, + }); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveText.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveText.test.ts new file mode 100644 index 00000000000..252ccd0994f --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveText.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "vitest"; + +import { + compactNodesToNodeStream, + CrdtType, + nodeStreamToCompactNodes, + type StorageNode, +} from "../../protocol/StorageNode"; +import { LiveText } from "../LiveText"; +import { invertTextOperations, transformTextOperations } from "../liveTextOps"; +import { toPlainLson } from "../utils"; + +describe("LiveText", () => { + test("stores plain text and serializes to JSON", () => { + const text = new LiveText("Hello"); + + expect(text.toString()).toBe("Hello"); + expect(text.length).toBe(5); + expect(text.toJSON()).toEqual([["Hello"]]); + expect(text.toJSON()).toEqual([["Hello"]]); + }); + + test("inserts, deletes, and replaces text", () => { + const text = new LiveText("Hello"); + + text.insert(5, " world"); + text.delete(0, 1); + text.replace(0, 4, "Hi"); + + expect(text.toString()).toBe("Hi world"); + expect(text.toJSON()).toEqual([["Hi world"]]); + }); + + test("formats ranges and normalizes adjacent segments", () => { + const text = new LiveText("Hello world"); + + text.format(0, 5, { bold: true }); + text.insert(5, "!", { bold: true }); + text.format(0, 6, { bold: null }); + + expect(text.toJSON()).toEqual([["Hello! world"]]); + }); + + test("clones without sharing mutable state", () => { + const text = new LiveText([["Hello", { bold: true }]]); + const clone = text.clone(); + + clone.insert(5, "!"); + + expect(text.toJSON()).toEqual([["Hello", { bold: true }]]); + expect(clone.toJSON()).toEqual([["Hello", { bold: true }], ["!"]]); + }); + + test("serializes to Plain LSON", () => { + const text = new LiveText([["Hello", { bold: true }]]); + + expect(toPlainLson(text)).toEqual({ + liveblocksType: "LiveText", + data: [["Hello", { bold: true }]], + version: 0, + }); + }); + + test("serializes to a DevTools tree node", () => { + const text = new LiveText( + [["Hello "], ["world", { "lb-comment": "thread-1" }]], + 3 + ); + + expect(text.toTreeNode("document")).toEqual({ + type: "LiveText", + id: expect.any(String), + key: "document", + payload: [ + { + type: "Json", + id: expect.stringMatching(/:0$/), + key: "0", + payload: ["Hello "], + }, + { + type: "Json", + id: expect.stringMatching(/:1$/), + key: "1", + payload: ["world", { "lb-comment": "thread-1" }], + }, + { + type: "Json", + id: expect.stringMatching(/:version$/), + key: "version", + payload: 3, + }, + ], + }); + }); + + test("transforms text operations over accepted operations", () => { + expect( + transformTextOperations( + [{ type: "insert", index: 1, text: "!" }], + [{ type: "insert", index: 0, text: "A" }], + "after" + ) + ).toEqual([{ type: "insert", index: 2, text: "!" }]); + }); + + test("invertTextOperations preserves attributes for multi-segment deletes", () => { + expect( + invertTextOperations( + [{ text: "He", attributes: { bold: true } }, { text: "llo" }], + [{ type: "delete", index: 0, length: 5 }] + ) + ).toEqual([ + { type: "insert", index: 0, text: "He", attributes: { bold: true } }, + { type: "insert", index: 2, text: "llo" }, + ]); + }); + + test("round-trips compact storage nodes", () => { + const nodes: StorageNode[] = [ + ["root", { type: CrdtType.OBJECT, data: {} }], + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 2, + }, + ], + ]; + + const compact = Array.from(nodeStreamToCompactNodes(nodes)); + + expect(compact).toEqual([ + ["root", {}], + ["0:1", CrdtType.TEXT, "root", "text", [["Hello"]], 2], + ]); + expect(Array.from(compactNodesToNodeStream(compact))).toEqual(nodes); + }); + + describe("multi-byte characters", () => { + // U+1F600, stored as a surrogate pair: one code point, two code units + const GRINNING = "😀"; + // "e" followed by U+0301 (combining acute accent): one grapheme, two code + // points + const COMBINED_E = "é"; + + test("indices count UTF-16 code units, like String.prototype", () => { + const text = new LiveText(`${GRINNING}${COMBINED_E}`); + + expect(text.length).toBe(4); + expect([...text.toString()]).toHaveLength(3); // code points + }); + + test("an insertion index inside a surrogate pair moves to the boundary", () => { + const text = new LiveText(GRINNING); + + // Index 1 sits between the surrogates; it snaps back to the start of + // the character rather than splitting it. + text.insert(1, "X"); + + expect(text.toString()).toBe(`X${GRINNING}`); + expect(text.length).toBe(3); + }); + + test("deleting part of a surrogate pair deletes the whole character", () => { + const text = new LiveText(`a${GRINNING}b`); + + text.delete(1, 1); + + expect(text.toString()).toBe("ab"); + }); + + test("deleting from the middle of a character covers it entirely", () => { + const text = new LiveText(`a${GRINNING}b`); + + // Starts between the surrogates and ends before "b" + text.delete(2, 1); + + expect(text.toString()).toBe("ab"); + }); + + test("replacing part of a surrogate pair replaces the whole character", () => { + const text = new LiveText(`a${GRINNING}b`); + + text.replace(2, 1, "X"); + + expect(text.toString()).toBe("aXb"); + }); + + test("formatting part of a surrogate pair formats the whole character", () => { + const text = new LiveText(GRINNING); + + text.format(0, 1, { bold: true }); + + expect(text.toJSON()).toEqual([[GRINNING, { bold: true }]]); + }); + + test("a combining mark is still separable from the letter it modifies", () => { + // The guarantee covers code points, not grapheme clusters: "é" here is + // two code points, and the accent can be edited on its own. + const text = new LiveText(COMBINED_E); + + text.delete(0, 1); + + expect(text.toString()).toBe("́"); + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/Orphaned.test.ts b/packages/liveblocks-core/src/crdts/__tests__/Orphaned.test.ts new file mode 100644 index 00000000000..331baeb39eb --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/Orphaned.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import * as console from "../../lib/fancy-console"; +import { OpCode } from "../../protocol/Op"; +import { createManagedPool } from "../AbstractCrdt"; +import { LiveList } from "../LiveList"; +import { LiveMap } from "../LiveMap"; +import { LiveObject } from "../LiveObject"; +import { LiveText } from "../LiveText"; +import type { LiveStructure } from "../Lson"; +import { REMOTE } from "../StorageUpdates"; + +const ORPHANED_NODE_WARNING = + "Cannot sync changes made to this Live structure because it is no longer part of Storage. Retrieve the current value from its parent before mutating it."; + +function orphan(node: T): T { + const pool = createManagedPool({ getCurrentConnectionId: () => 0 }); + const root = new LiveObject<{ child?: T }>({ child: node }); + root._attach("root", pool); + root.delete("child"); + return node; +} + +describe("orphaned Live structures", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("a replaced LiveText warns when mutated", () => { + const pool = createManagedPool({ getCurrentConnectionId: () => 0 }); + const text = new LiveText(); + const root = new LiveObject({ text }); + root._attach("root", pool); + + root._attachChild( + { + type: OpCode.CREATE_TEXT, + id: "1:0", + parentId: "root", + parentKey: "text", + data: [], + version: 0, + }, + REMOTE + ); + + expect(root.get("text")).not.toBe(text); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + text.insert(0, "lost"); + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(ORPHANED_NODE_WARNING, { + type: "LiveText", + formerParentKey: "text", + }); + expect(text.toString()).toBe("lost"); + }); + + test("LiveText mutations warn once", () => { + const text = orphan(new LiveText("abc")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + text.insert(1, "x"); + text.delete(1, 1); + text.replace(1, 1, "x"); + text.format(1, 1, { bold: true }); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(ORPHANED_NODE_WARNING, { + type: "LiveText", + formerParentKey: "child", + }); + expect(text.toString()).toBe("axc"); + }); + + test("LiveObject mutations warn once", () => { + const object = orphan( + new LiveObject<{ local?: number; value: number }>({ value: 0 }) + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + object.set("value", 1); + object.setLocal("local", 1); + object.update({ value: 1 }); + object.delete("value"); + object.reconcile({ value: 1 }); + object.reconcilePartially({ value: 1 }); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(ORPHANED_NODE_WARNING, { + type: "LiveObject", + formerParentKey: "child", + }); + expect(object.toJSON()).toEqual({ value: 1 }); + }); + + test("LiveMap mutations warn once", () => { + const map = orphan(new LiveMap([["value", 0]])); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + map.set("value", 1); + map.delete("value"); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(ORPHANED_NODE_WARNING, { + type: "LiveMap", + formerParentKey: "child", + }); + expect(map.toJSON()).toEqual({}); + }); + + test("LiveList mutations warn once", () => { + const list = orphan(new LiveList([0, 1])); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + list.push(2); + list.insert(2, 1); + list.move(0, 1); + list.delete(0); + list.clear(); + list.push(1); + list.set(0, 2); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(ORPHANED_NODE_WARNING, { + type: "LiveList", + formerParentKey: "child", + }); + expect(list.toJSON()).toEqual([2]); + }); + +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/UnacknowledgedOps.test.ts b/packages/liveblocks-core/src/crdts/__tests__/UnacknowledgedOps.test.ts new file mode 100644 index 00000000000..64255246389 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/UnacknowledgedOps.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; + +import type { ClientWireOp } from "../../protocol/Op"; +import { OpCode } from "../../protocol/Op"; +import { UnacknowledgedOps } from "../UnacknowledgedOps"; + +describe("UnacknowledgedOps", () => { + test("indexes pending CreateTextOps by parent and position", () => { + const unacked = new UnacknowledgedOps(); + + const createTextOp: ClientWireOp = { + type: OpCode.CREATE_TEXT, + opId: "1:0", + id: "1:1", + parentId: "0:0", + parentKey: "!", + data: [["Hello"]], + version: 0, + }; + unacked.add(createTextOp); + + expect(unacked.get("1:0")).toBe(createTextOp); + expect(Array.from(unacked.getByParentId("0:0"))).toEqual([createTextOp]); + expect(Array.from(unacked.getByParentIdAndKey("0:0", "!"))).toEqual([ + createTextOp, + ]); + + unacked.delete("1:0"); + + expect(unacked.get("1:0")).toBeUndefined(); + expect(Array.from(unacked.getByParentId("0:0"))).toEqual([]); + expect(Array.from(unacked.getByParentIdAndKey("0:0", "!"))).toEqual([]); + }); + + test("does not index non-create ops by position", () => { + const unacked = new UnacknowledgedOps(); + + const updateTextOp: ClientWireOp = { + type: OpCode.UPDATE_TEXT, + opId: "1:0", + id: "1:1", + baseVersion: 0, + ops: [{ type: "insert", index: 0, text: "x" }], + }; + unacked.add(updateTextOp); + + expect(unacked.get("1:0")).toBe(updateTextOp); + expect(Array.from(unacked.getByParentId("0:0"))).toEqual([]); + + unacked.delete("1:0"); + expect(unacked.size).toBe(0); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/_arbitraries.ts b/packages/liveblocks-core/src/crdts/__tests__/_arbitraries.ts index 14ade3e6508..8b1fbac4286 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/_arbitraries.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/_arbitraries.ts @@ -5,6 +5,7 @@ import { LiveFile } from "../LiveFile"; import { LiveList } from "../LiveList"; import { LiveMap } from "../LiveMap"; import { LiveObject } from "../LiveObject"; +import { LiveText } from "../LiveText"; import type { LiveStructure, Lson } from "../Lson"; export const key = fc.string().filter((s) => s !== "__proto__"); @@ -53,6 +54,7 @@ function makeLsonArbitraries(options?: LsonArbitraryOptions) { tie("liveFile"), tie("liveList"), tie("liveObject"), + tie("liveText"), ...(withLiveMap ? [tie("liveMap")] : []), ] ) @@ -61,6 +63,7 @@ function makeLsonArbitraries(options?: LsonArbitraryOptions) { liveMap: fc .array(fc.tuple(key, tie("lson"))) .map((pairs) => new LiveMap(pairs as [string, Lson][])), + liveText: fc.string().map((text) => new LiveText(text)), liveObject: fc .array(fc.tuple(key, tie("lson"))) .map( diff --git a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts index bfcaf55cf06..50221925901 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts @@ -31,6 +31,7 @@ describe("Storage", () => { type: "LiveObject", node: storage.root, updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); }); @@ -61,6 +62,7 @@ describe("Storage", () => { type: "LiveObject", node: storageA.root, updates: { a: { type: "update" } }, + source: { origin: "remote" }, }, ]); }); @@ -95,6 +97,7 @@ describe("Storage", () => { type: "LiveObject", node: storageA.root, updates: { a: { type: "update" }, b: { type: "update" } }, + source: { origin: "remote" }, }, ]); }); @@ -136,6 +139,7 @@ describe("Storage", () => { type: "LiveObject", node: storage.root, updates: { a: { type: "update" }, b: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); @@ -171,11 +175,13 @@ describe("Storage", () => { type: "LiveObject", node: storage.root, updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, { type: "LiveObject", node: root.get("child"), updates: { b: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); }); @@ -215,21 +221,25 @@ describe("Storage", () => { type: "LiveObject", node: storage.root, updates: { a: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, { type: "LiveObject", node: root.get("childObj"), updates: { b: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, { type: "LiveList", node: root.get("childList"), updates: [{ index: 0, item: "item1", type: "insert" }], + source: { origin: "local", via: "edit" }, }, { type: "LiveMap", node: root.get("childMap"), updates: { el1: { type: "update" } }, + source: { origin: "local", via: "edit" }, }, ]); }); diff --git a/packages/liveblocks-core/src/crdts/__tests__/liveTextOps.test.ts b/packages/liveblocks-core/src/crdts/__tests__/liveTextOps.test.ts new file mode 100644 index 00000000000..77d5f4b78f5 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/liveTextOps.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "vitest"; + +import type { LiveTextData } from "../../protocol/Op"; +import { + applyLiveTextOperations, + attributesEqual, + dataToSegments, + inverseMapTextIndexThroughOperations, + invertTextOperations, + mapTextIndexThroughOperations, + normalizeLiveTextOperations, + normalizeSegments, + transformTextOperations, +} from "../liveTextOps"; + +describe("liveTextOps", () => { + test("attributesEqual is order-independent", () => { + expect(attributesEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + expect(attributesEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + test("inverting a format clears attribute names inherited from Object.prototype", () => { + // Attribute names are arbitrary strings, so they can collide with members + // every plain object inherits from Object.prototype ("toString", + // "constructor", "valueOf", ...). Undoing a format on a key the segment + // never had must restore "no such attribute" (null) -- the inherited + // member is not an attribute value. + // + // The segment carries an unrelated attribute here so its attribute bag + // exists: that is what makes the inherited "toString" reachable. + const doc: LiveTextData = [["a", { bold: true }]]; + const ops = [ + { + type: "format" as const, + index: 0, + length: 1, + attributes: { toString: false }, + }, + ]; + + const reverse = invertTextOperations(dataToSegments(doc), ops); + + expect(reverse).toEqual([ + { type: "format", index: 0, length: 1, attributes: { toString: null } }, + ]); + expect( + applyLiveTextOperations(applyLiveTextOperations(doc, ops), reverse) + ).toEqual(doc); + }); + + test("normalizeSegments merges adjacent segments with equivalent attributes", () => { + expect( + normalizeSegments([ + { text: "He", attributes: { bold: true } }, + { text: "llo", attributes: { bold: true } }, + ]) + ).toEqual([{ text: "Hello", attributes: { bold: true } }]); + }); + + test("applyLiveTextOperations inserts, deletes, and formats", () => { + const data = applyLiveTextOperations( + [["Hello"]], + [ + { type: "insert", index: 5, text: "!" }, + { type: "format", index: 0, length: 5, attributes: { bold: true } }, + ] + ); + + expect(data).toEqual([["Hello", { bold: true }], ["!"]]); + }); + + test("normalizes operation boundaries around surrogate pairs", () => { + const data: LiveTextData = [["a😀b"]]; + + expect( + normalizeLiveTextOperations(data, [ + { type: "insert", index: 2, text: "X" }, + ]) + ).toEqual([{ type: "insert", index: 1, text: "X" }]); + + expect( + normalizeLiveTextOperations(data, [ + { type: "delete", index: 2, length: 1 }, + ]) + ).toEqual([{ type: "delete", index: 1, length: 2 }]); + + expect( + normalizeLiveTextOperations(data, [ + { type: "delete", index: 0, length: 2 }, + ]) + ).toEqual([{ type: "delete", index: 0, length: 3 }]); + + expect( + normalizeLiveTextOperations(data, [ + { type: "delete", index: 2, length: 0 }, + ]) + ).toEqual([{ type: "delete", index: 1, length: 0 }]); + + expect( + normalizeLiveTextOperations(data, [ + { + type: "format", + index: 2, + length: 1, + attributes: { bold: true }, + }, + ]) + ).toEqual([ + { + type: "format", + index: 1, + length: 2, + attributes: { bold: true }, + }, + ]); + }); + + test("normalizes each operation against the preceding operations", () => { + expect( + normalizeLiveTextOperations( + [["ab"]], + [ + { type: "insert", index: 1, text: "😀" }, + { type: "insert", index: 2, text: "X" }, + ] + ) + ).toEqual([ + { type: "insert", index: 1, text: "😀" }, + { type: "insert", index: 1, text: "X" }, + ]); + }); + + test("detects surrogate pairs across segment boundaries", () => { + expect( + normalizeLiveTextOperations( + [["a\ud83d"], ["\ude00b", { bold: true }]], + [{ type: "insert", index: 2, text: "X" }] + ) + ).toEqual([{ type: "insert", index: 1, text: "X" }]); + }); + + test("invertTextOperations preserves attributes for deleted segments", () => { + const segments = dataToSegments([["He", { bold: true }], ["llo"]]); + + expect( + invertTextOperations(segments, [{ type: "delete", index: 0, length: 5 }]) + ).toEqual([ + { type: "insert", index: 0, text: "He", attributes: { bold: true } }, + { type: "insert", index: 2, text: "llo" }, + ]); + }); + + test("transformTextOperations shifts indices over accepted inserts", () => { + expect( + transformTextOperations( + [{ type: "insert", index: 1, text: "!" }], + [{ type: "insert", index: 0, text: "A" }], + "after" + ) + ).toEqual([{ type: "insert", index: 2, text: "!" }]); + }); + + describe("inverseMapTextIndexThroughOperations", () => { + test("identity when there are no ops", () => { + expect(inverseMapTextIndexThroughOperations(7, [])).toBe(7); + }); + + test("undoes a single insert: positions past insertion shift left", () => { + const op = { type: "insert" as const, index: 5, text: "ab" }; + expect(inverseMapTextIndexThroughOperations(8, [op])).toBe(6); + }); + + test("undoes a single insert: positions before insertion are unchanged", () => { + const op = { type: "insert" as const, index: 5, text: "ab" }; + expect(inverseMapTextIndexThroughOperations(3, [op])).toBe(3); + }); + + test("undoes a single insert: positions inside insertion collapse to insertion point", () => { + const op = { type: "insert" as const, index: 5, text: "abc" }; + expect(inverseMapTextIndexThroughOperations(5, [op])).toBe(5); + expect(inverseMapTextIndexThroughOperations(6, [op])).toBe(5); + expect(inverseMapTextIndexThroughOperations(7, [op])).toBe(5); + expect(inverseMapTextIndexThroughOperations(8, [op])).toBe(5); + expect(inverseMapTextIndexThroughOperations(9, [op])).toBe(6); + }); + + test("undoes a single delete: positions before deletion are unchanged", () => { + const op = { type: "delete" as const, index: 5, length: 2 }; + expect(inverseMapTextIndexThroughOperations(3, [op])).toBe(3); + expect(inverseMapTextIndexThroughOperations(4, [op])).toBe(4); + }); + + test("undoes a single delete: positions past deletion shift right", () => { + const op = { type: "delete" as const, index: 5, length: 2 }; + expect(inverseMapTextIndexThroughOperations(6, [op])).toBe(8); + expect(inverseMapTextIndexThroughOperations(10, [op])).toBe(12); + }); + + test("undoes a single delete: position at the deletion point lands on the right edge", () => { + const op = { type: "delete" as const, index: 5, length: 3 }; + expect(inverseMapTextIndexThroughOperations(5, [op])).toBe(8); + }); + + test("format ops are positionally neutral", () => { + const op = { + type: "format" as const, + index: 1, + length: 4, + attributes: { bold: true }, + }; + expect(inverseMapTextIndexThroughOperations(3, [op])).toBe(3); + }); + + test("ops are inverted in reverse order", () => { + const ops = [ + { type: "insert" as const, index: 0, text: "Hi " }, + { type: "delete" as const, index: 6, length: 1 }, + ]; + // Forward: from "World" → "Hi World" → "Hi Wold" (delete the "r" at index 6). + // Position 4 in the final string ("Wo|ld") should inverse-map back to + // position 1 in the original "World" ("W|orld"), which the forward map + // confirms: forward(1, ops) = 4. + expect(mapTextIndexThroughOperations(1, ops)).toBe(4); + expect(inverseMapTextIndexThroughOperations(4, ops)).toBe(1); + }); + + test("forward then inverse is identity on positions clearly outside any op range", () => { + const ops = [ + { type: "insert" as const, index: 2, text: "XY" }, + { type: "delete" as const, index: 10, length: 3 }, + ]; + for (const index of [0, 1, 15, 20, 100]) { + expect( + inverseMapTextIndexThroughOperations( + mapTextIndexThroughOperations(index, ops), + ops + ) + ).toBe(index); + } + }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts index 7af6a6fea37..da6c3622163 100644 --- a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts +++ b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts @@ -20,6 +20,7 @@ import { import { LiveList } from "../LiveList"; import { LiveMap } from "../LiveMap"; import { LiveObject } from "../LiveObject"; +import { LiveText } from "../LiveText"; import { toPlainLson } from "../utils"; test("Common first positions", () => { @@ -318,6 +319,71 @@ describe("diffNodeMap", () => { ]); }); + test("liveText create and update", () => { + const currentItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + ]); + + const newItems: NodeMap = new Map([ + ["root", { type: CrdtType.OBJECT, data: {} }], + [ + "0:1", + { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 0, + }, + ], + ]); + + expect(diffNodeMap(currentItems, newItems)).toEqual([ + { + type: OpCode.CREATE_TEXT, + id: "0:1", + parentId: "root", + parentKey: "text", + data: [["Hello"]], + version: 0, + }, + ]); + + const updatedItems: NodeMap = new Map(newItems); + updatedItems.set("0:1", { + type: CrdtType.TEXT, + parentId: "root", + parentKey: "text", + data: [["Hello!"]], + version: 1, + }); + + // Content changes of existing LiveText nodes are deliberately NOT part + // of the op diff: snapshots are reconciled via LiveText._resyncText + // (driven by the room), not via UPDATE_TEXT ops. + expect(diffNodeMap(newItems, updatedItems)).toEqual([]); + + // A user-initiated restore is different from an authoritative snapshot + // load: it is a new edit in the current timeline. Use the current node's + // version as the base and treat the target's older version as content-only + // snapshot metadata. + expect( + diffNodeMap(updatedItems, newItems, { + includeLiveTextUpdates: true, + }) + ).toEqual([ + { + type: OpCode.UPDATE_TEXT, + id: "0:1", + baseVersion: 1, + ops: [ + { type: "delete", index: 0, length: 6 }, + { type: "insert", index: 0, text: "Hello", attributes: undefined }, + ], + }, + ]); + }); + test("liveObject replacing a non-object node of the same id", () => { const currentItems: NodeMap = new Map([ ["root", { type: CrdtType.OBJECT, data: {} }], @@ -603,6 +669,7 @@ describe("toPlainLson", () => { ["broccoli", "delicious"], ["spinach", "also tasty"], ]), + text: new LiveText("Hello"), }); // What the Plain Lson should look like if the util works @@ -617,6 +684,11 @@ describe("toPlainLson", () => { liveblocksType: "LiveMap", data: { broccoli: "delicious", spinach: "also tasty" }, }, + text: { + liveblocksType: "LiveText", + data: [["Hello"]], + version: 0, + }, }, }; diff --git a/packages/liveblocks-core/src/crdts/__tests__/storageUpdateSource.test.ts b/packages/liveblocks-core/src/crdts/__tests__/storageUpdateSource.test.ts new file mode 100644 index 00000000000..d0460411fb9 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/storageUpdateSource.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest"; + +import { mergeStorageUpdates } from "../liveblocks-helpers"; +import { LiveObject } from "../LiveObject"; +import { LiveText } from "../LiveText"; +import type { StorageUpdate, UpdateSource } from "../StorageUpdates"; +import { toUpdateSource } from "../StorageUpdates"; + +function liveObjectUpdate(source: UpdateSource): StorageUpdate { + return { + type: "LiveObject", + node: new LiveObject({ a: 1 }), + updates: { a: { type: "update" } }, + source, + }; +} + +function liveTextUpdate(source: UpdateSource): StorageUpdate { + return { + type: "LiveText", + node: new LiveText("hello"), + version: 1, + updates: [{ type: "insert", index: 5, text: "!" }], + source, + }; +} + +describe("mergeStorageUpdates source propagation", () => { + test("both local mutation -> merged is local mutation", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "local", via: "edit" }), + liveObjectUpdate({ origin: "local", via: "edit" }) + ); + expect(merged.source).toEqual({ + origin: "local", + via: "edit", + }); + }); + + test("both remote -> merged is remote", () => { + const merged = mergeStorageUpdates( + liveTextUpdate({ origin: "remote" }), + liveTextUpdate({ origin: "remote" }) + ); + expect(merged.source).toEqual({ origin: "remote" }); + }); + + test("mixed local and remote -> merged is remote", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "local", via: "edit" }), + liveObjectUpdate({ origin: "remote" }) + ); + expect(merged.source).toEqual({ origin: "remote" }); + }); + + test("mixed remote and local -> merged is remote", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "remote" }), + liveObjectUpdate({ origin: "local", via: "edit" }) + ); + expect(merged.source).toEqual({ origin: "remote" }); + }); + + test("mixed local edit and undo -> merged is undo", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "local", via: "edit" }), + liveObjectUpdate({ origin: "local", via: "undo" }) + ); + expect(merged.source).toEqual({ + origin: "local", + via: "undo", + }); + }); + + test("mixed undo and local edit -> merged is undo", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "local", via: "undo" }), + liveObjectUpdate({ origin: "local", via: "edit" }) + ); + expect(merged.source).toEqual({ + origin: "local", + via: "undo", + }); + }); + + test("mixed undo and redo -> merged keeps second one", () => { + const merged = mergeStorageUpdates( + liveObjectUpdate({ origin: "local", via: "undo" }), + liveObjectUpdate({ origin: "local", via: "redo" }) + ); + expect(merged.source).toEqual({ + origin: "local", + via: "redo", + }); + }); + + test("undefined first preserves second source", () => { + const merged = mergeStorageUpdates( + undefined, + liveTextUpdate({ origin: "remote" }) + ); + expect(merged.source).toEqual({ origin: "remote" }); + }); +}); + +describe("toUpdateSource", () => { + test("drops the internal optimistic flag from local sources", () => { + for (const via of ["edit", "undo", "redo"] as const) { + for (const optimistic of [true, false]) { + expect(toUpdateSource({ origin: "local", via, optimistic })).toEqual({ + origin: "local", + via, + }); + } + } + }); + + test("leaves remote sources alone", () => { + expect(toUpdateSource({ origin: "remote" })).toEqual({ origin: "remote" }); + }); +}); diff --git a/packages/liveblocks-core/src/crdts/liveTextOps.ts b/packages/liveblocks-core/src/crdts/liveTextOps.ts new file mode 100644 index 00000000000..ed0ac481e31 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/liveTextOps.ts @@ -0,0 +1,829 @@ +import { freeze } from "../lib/freeze"; +import type { Json, JsonObject } from "../lib/Json"; +import { stableStringify } from "../lib/stringify"; +import type { + LiveTextData, + TextAttributes, + TextOperation, +} from "../protocol/Op"; + +export type TextSegment = { + text: string; + attributes?: TextAttributes; +}; + +export function attributesEqual( + left: TextAttributes | undefined, + right: TextAttributes | undefined +): boolean { + if (left === right) { + return true; + } + if (left === undefined || right === undefined) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (left[key] !== right[key]) { + return false; + } + } + return true; +} + +function cloneAttributes( + attributes: TextAttributes | undefined +): TextAttributes | undefined { + return attributes === undefined ? undefined : freeze({ ...attributes }); +} + +export function normalizeSegments( + segments: readonly TextSegment[] +): TextSegment[] { + const normalized: TextSegment[] = []; + for (const segment of segments) { + if (segment.text.length === 0) { + continue; + } + + const last = normalized.at(-1); + const attributes = cloneAttributes(segment.attributes); + if (last !== undefined && attributesEqual(last.attributes, attributes)) { + last.text += segment.text; + } else { + normalized.push({ text: segment.text, attributes }); + } + } + return normalized; +} + +export function dataToSegments(data: LiveTextData): TextSegment[] { + return normalizeSegments( + data.map(([text, attributes]) => ({ + text, + attributes, + })) + ); +} + +export function segmentsToData(segments: readonly TextSegment[]): LiveTextData { + return segments.map((segment) => + segment.attributes === undefined + ? [segment.text] + : [segment.text, { ...segment.attributes }] + ); +} + +export function textLength(segments: readonly TextSegment[]): number { + return segments.reduce((sum, segment) => sum + segment.text.length, 0); +} + +export function splitSegmentsAt( + segments: readonly TextSegment[], + index: number +): TextSegment[] { + const result: TextSegment[] = []; + let offset = 0; + + for (const segment of segments) { + const end = offset + segment.text.length; + if (index > offset && index < end) { + const before = segment.text.slice(0, index - offset); + const after = segment.text.slice(index - offset); + result.push({ text: before, attributes: segment.attributes }); + result.push({ text: after, attributes: segment.attributes }); + } else { + result.push({ text: segment.text, attributes: segment.attributes }); + } + offset = end; + } + + return result; +} + +export function clipRange( + index: number, + length: number, + contentLength: number +): { index: number; length: number } { + const clippedIndex = Math.max(0, Math.min(index, contentLength)); + const clippedEnd = Math.max( + clippedIndex, + Math.min(index + length, contentLength) + ); + return { index: clippedIndex, length: clippedEnd - clippedIndex }; +} + +// Some characters (like emojis) are represented by surrogate pairs +// The first part of the pair is always between 0xD800-0xDBFF +// The second part is always between 0xDC00 and 0XDFFF +function isInSurrogatePair(text: string, index: number): boolean { + const previous = text.charCodeAt(index - 1); + const next = text.charCodeAt(index); + + return ( + previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff + ); +} + +/** + * Clips an index to the text and, if it falls inside a UTF-16 surrogate pair, + * moves it to the start of that code point. + */ +export function clipIndexToCodePointBoundary( + text: string, + index: number +): number { + const clippedIndex = Math.max(0, Math.min(index, text.length)); + return isInSurrogatePair(text, clippedIndex) + ? clippedIndex - 1 + : clippedIndex; +} + +/** + * Clips a range to the text and expands its boundaries when necessary so it + * never covers only part of a UTF-16 surrogate pair. + */ +export function clipRangeToCodePointBoundaries( + text: string, + index: number, + length: number +): { index: number; length: number } { + const clipped = clipRange(index, length, text.length); + + if (clipped.length === 0) { + return { + index: clipIndexToCodePointBoundary(text, clipped.index), + length: 0, + }; + } + + const clippedEnd = clipped.index + clipped.length; + const normalizedIndex = isInSurrogatePair(text, clipped.index) + ? clipped.index - 1 + : clipped.index; + const normalizedEnd = isInSurrogatePair(text, clippedEnd) + ? clippedEnd + 1 + : clippedEnd; + + return { + index: normalizedIndex, + length: normalizedEnd - normalizedIndex, + }; +} + +export function applyInsert( + segments: readonly TextSegment[], + index: number, + text: string, + attributes?: TextAttributes +): TextSegment[] { + if (text.length === 0) { + return normalizeSegments(segments); + } + + const split = splitSegmentsAt(segments, index); + const result: TextSegment[] = []; + let offset = 0; + let inserted = false; + + for (const segment of split) { + if (!inserted && offset === index) { + result.push({ text, attributes }); + inserted = true; + } + result.push(segment); + offset += segment.text.length; + } + + if (!inserted) { + result.push({ text, attributes }); + } + + return normalizeSegments(result); +} + +export function extractDeletedSegments( + segments: readonly TextSegment[], + index: number, + length: number +): TextSegment[] { + const split = splitSegmentsAt( + splitSegmentsAt(segments, index), + index + length + ); + const deleted: TextSegment[] = []; + let offset = 0; + + for (const segment of split) { + const end = offset + segment.text.length; + if (offset >= index && end <= index + length) { + deleted.push({ + text: segment.text, + attributes: segment.attributes, + }); + } + offset = end; + } + + return normalizeSegments(deleted); +} + +export function applyDelete( + segments: readonly TextSegment[], + index: number, + length: number +): { + segments: TextSegment[]; + deletedText: string; + deletedSegments: TextSegment[]; +} { + const deletedSegments = extractDeletedSegments(segments, index, length); + const split = splitSegmentsAt( + splitSegmentsAt(segments, index), + index + length + ); + const result: TextSegment[] = []; + let offset = 0; + let deletedText = ""; + + for (const segment of split) { + const end = offset + segment.text.length; + if (offset >= index && end <= index + length) { + deletedText += segment.text; + } else { + result.push(segment); + } + offset = end; + } + + return { + segments: normalizeSegments(result), + deletedText, + deletedSegments, + }; +} + +export function applyFormat( + segments: readonly TextSegment[], + index: number, + length: number, + attributes: JsonObject +): TextSegment[] { + const split = splitSegmentsAt( + splitSegmentsAt(segments, index), + index + length + ); + const result: TextSegment[] = []; + let offset = 0; + + for (const segment of split) { + const end = offset + segment.text.length; + if (offset >= index && end <= index + length) { + const nextAttributes: JsonObject = { + ...(segment.attributes ?? {}), + }; + for (const [key, value] of Object.entries(attributes)) { + if (value === null) { + delete nextAttributes[key]; + } else { + nextAttributes[key] = value; + } + } + result.push({ + text: segment.text, + attributes: + Object.keys(nextAttributes).length === 0 + ? undefined + : freeze(nextAttributes), + }); + } else { + result.push(segment); + } + offset = end; + } + + return normalizeSegments(result); +} + +export function formatReverseOperations( + segments: readonly TextSegment[], + index: number, + length: number, + patch: JsonObject +): TextOperation[] { + const split = splitSegmentsAt( + splitSegmentsAt(segments, index), + index + length + ); + const result: TextOperation[] = []; + let offset = 0; + + for (const segment of split) { + const end = offset + segment.text.length; + if (offset >= index && end <= index + length) { + const attributes: Record = {}; + const current = segment.attributes ?? {}; + for (const key of Object.keys(patch)) { + // Own keys only: attribute names can collide with Object.prototype + // members like "toString" + const value = Object.hasOwn(current, key) ? current[key] : undefined; + attributes[key] = value ?? null; + } + result.push({ + type: "format", + index: offset, + length: segment.text.length, + attributes, + }); + } + offset = end; + } + + return result; +} + +function mapIndexThroughOperation(index: number, op: TextOperation): number { + if (op.type === "insert") { + return op.index <= index ? index + op.text.length : index; + } else if (op.type === "delete") { + if (op.index >= index) { + return index; + } + return Math.max(op.index, index - op.length); + } else { + return index; + } +} + +export function mapTextIndexThroughOperations( + index: number, + ops: readonly TextOperation[] +): number { + let mapped = index; + for (const op of ops) { + mapped = mapIndexThroughOperation(mapped, op); + } + return mapped; +} + +/** + * Inverse of {@link mapIndexThroughOperation}: given an index in the + * document *after* `op` was applied, return an equivalent index in the + * document *before* it was applied. + * + * At ambiguous boundaries this picks the conventional inverse of + * {@link mapIndexThroughOperation}: + * - For an insert, positions inside the inserted range collapse to the + * left edge (the original insertion point). + * - For a delete, a position at the deletion site is mapped to the right + * edge of where the deleted range used to be. + */ +function inverseMapIndexThroughOperation( + index: number, + op: TextOperation +): number { + if (op.type === "insert") { + if (index <= op.index) { + return index; + } + return Math.max(op.index, index - op.text.length); + } else if (op.type === "delete") { + return op.index <= index ? index + op.length : index; + } else { + return index; + } +} + +/** + * Inverse of {@link mapTextIndexThroughOperations}: given an index in the + * document *after* `ops` were applied in order, return an equivalent index + * in the document *before* any of them were applied. Inverts the ops in + * reverse order. + */ +export function inverseMapTextIndexThroughOperations( + index: number, + ops: readonly TextOperation[] +): number { + let mapped = index; + for (let i = ops.length - 1; i >= 0; i--) { + mapped = inverseMapIndexThroughOperation(mapped, ops[i]); + } + return mapped; +} + +// ----------------------------------------------------------------------------- +// Operational transform +// ----------------------------------------------------------------------------- + +/** + * The position of the ops being transformed relative to the ops they are + * transformed over, in the final (server-serialized) timeline: + * + * - "after": the transformed ops will be ordered after the `over` ops. Used + * when rebasing a not-yet-accepted op over already-accepted ops. On + * same-index insert ties, the transformed op shifts right (the earlier op + * stays left), and conflicting format attributes are kept (they will + * overwrite, since the op applies later). + * - "before": the transformed ops were ordered before the `over` ops. Used + * when applying an accepted remote op on top of locally-pending ops. On + * same-index insert ties, the transformed op stays left, and conflicting + * format attributes are dropped on overlapping ranges (the later `over` op + * wins). + */ +export type TransformOrder = "before" | "after"; + +function oppositeOrder(order: TransformOrder): TransformOrder { + return order === "before" ? "after" : "before"; +} + +function mapIndexOverDelete( + index: number, + deleteIndex: number, + deleteLength: number +): number { + if (deleteIndex >= index) { + return index; + } + return Math.max(deleteIndex, index - deleteLength); +} + +/** + * Transform a single insert op over a single op. Both ops must be expressed + * against the same document state. + */ +function transformInsert( + op: TextOperation & { type: "insert" }, + over: TextOperation, + order: TransformOrder +): TextOperation[] { + if (over.type === "insert") { + const shifts = + over.index < op.index || (over.index === op.index && order === "after"); + return [shifts ? { ...op, index: op.index + over.text.length } : { ...op }]; + } else if (over.type === "delete") { + return [ + { ...op, index: mapIndexOverDelete(op.index, over.index, over.length) }, + ]; + } else { + return [{ ...op }]; + } +} + +/** + * Transform a single delete op over a single op. A delete spanning a + * concurrent insert is split into two deletes so the inserted text survives. + * The returned ops use sequential application semantics (each op applies to + * the result of the previous one). + */ +function transformDelete( + op: TextOperation & { type: "delete" }, + over: TextOperation +): TextOperation[] { + const start = op.index; + const end = op.index + op.length; + + if (over.type === "insert") { + const at = over.index; + const len = over.text.length; + if (at <= start) { + return [{ ...op, index: start + len }]; + } + if (at >= end) { + return [{ ...op }]; + } + // The insert lands strictly inside the deleted range: split the delete so + // the concurrently inserted text is preserved. After the first piece is + // applied, the inserted text sits at `start`, and the remainder of the + // original range sits right after it. + return [ + { type: "delete", index: start, length: at - start }, + { type: "delete", index: start + len, length: end - at }, + ]; + } else if (over.type === "delete") { + const newStart = mapIndexOverDelete(start, over.index, over.length); + const newEnd = mapIndexOverDelete(end, over.index, over.length); + return newEnd - newStart > 0 + ? [{ type: "delete", index: newStart, length: newEnd - newStart }] + : []; + } else { + return [{ ...op }]; + } +} + +/** + * Transform a single format op over a single op. Like deletes, a format + * spanning a concurrent insert is split so the inserted text is not formatted. + * For overlapping concurrent formats, the op that is ordered later in the + * final timeline wins conflicting attribute keys: when `order` is "before", + * the transformed op drops the keys that `over` also sets on the overlapping + * range. + */ +function transformFormat( + op: TextOperation & { type: "format" }, + over: TextOperation, + order: TransformOrder +): TextOperation[] { + const start = op.index; + const end = op.index + op.length; + + if (over.type === "insert") { + const at = over.index; + const len = over.text.length; + if (at <= start) { + return [{ ...op, index: start + len }]; + } + if (at >= end) { + return [{ ...op }]; + } + return [ + { + type: "format", + index: start, + length: at - start, + attributes: op.attributes, + }, + { + type: "format", + index: at + len, + length: end - at, + attributes: op.attributes, + }, + ]; + } else if (over.type === "delete") { + const newStart = mapIndexOverDelete(start, over.index, over.length); + const newEnd = mapIndexOverDelete(end, over.index, over.length); + return newEnd - newStart > 0 + ? [ + { + type: "format", + index: newStart, + length: newEnd - newStart, + attributes: op.attributes, + }, + ] + : []; + } else { + if (order === "after") { + // This op applies later and naturally overwrites; nothing to do. + return [{ ...op }]; + } + + const overlapStart = Math.max(start, over.index); + const overlapEnd = Math.min(end, over.index + over.length); + if (overlapStart >= overlapEnd) { + return [{ ...op }]; + } + + const hasConflict = Object.keys(op.attributes).some((key) => + Object.hasOwn(over.attributes, key) + ); + if (!hasConflict) { + return [{ ...op }]; + } + + const reduced: JsonObject = {}; + for (const [key, value] of Object.entries(op.attributes)) { + if (!Object.hasOwn(over.attributes, key)) { + reduced[key] = value; + } + } + + const pieces: TextOperation[] = []; + if (start < overlapStart) { + pieces.push({ + type: "format", + index: start, + length: overlapStart - start, + attributes: op.attributes, + }); + } + if (Object.keys(reduced).length > 0) { + pieces.push({ + type: "format", + index: overlapStart, + length: overlapEnd - overlapStart, + attributes: reduced, + }); + } + if (overlapEnd < end) { + pieces.push({ + type: "format", + index: overlapEnd, + length: end - overlapEnd, + attributes: op.attributes, + }); + } + return pieces; + } +} + +function transformSingle( + op: TextOperation, + over: TextOperation, + order: TransformOrder +): TextOperation[] { + switch (op.type) { + case "insert": + return transformInsert(op, over, order); + case "delete": + return transformDelete(op, over); + case "format": + return transformFormat(op, over, order); + } +} + +/** + * Transform op sequence A against op sequence B, where both sequences are + * expressed against the same base document state and each sequence uses + * sequential application semantics internally. + * + * Returns [A', B'] such that: + * - A' is A transformed to apply after B (i.e. against base ⊕ B), and + * - B' is B transformed to apply after A (i.e. against base ⊕ A), + * and base ⊕ A ⊕ B' === base ⊕ B ⊕ A' (TP1). + * + * `order` is A's position relative to B in the final timeline. + */ +export function transformTextOperationsX( + a: readonly TextOperation[], + b: readonly TextOperation[], + order: TransformOrder +): [TextOperation[], TextOperation[]] { + if (a.length === 0 || b.length === 0) { + return [[...a], [...b]]; + } + + if (a.length === 1 && b.length === 1) { + return [ + transformSingle(a[0], b[0], order), + transformSingle(b[0], a[0], oppositeOrder(order)), + ]; + } + + if (a.length > 1) { + const [headA1, b1] = transformTextOperationsX([a[0]], b, order); + const [restA1, b2] = transformTextOperationsX(a.slice(1), b1, order); + return [[...headA1, ...restA1], b2]; + } + + const [a1, headB1] = transformTextOperationsX(a, [b[0]], order); + const [a2, restB1] = transformTextOperationsX(a1, b.slice(1), order); + return [a2, [...headB1, ...restB1]]; +} + +/** + * Transform `ops` over `over` (see {@link transformTextOperationsX}), + * returning only the transformed `ops`. + */ +export function transformTextOperations( + ops: readonly TextOperation[], + over: readonly TextOperation[], + order: TransformOrder +): TextOperation[] { + return transformTextOperationsX(ops, over, order)[0]; +} + +/** + * Structural equality of two operation sequences (order-insensitive for + * attribute keys). + */ +export function textOperationsEqual( + a: readonly TextOperation[], + b: readonly TextOperation[] +): boolean { + return a === b || stableStringify(a) === stableStringify(b); +} + +export function applyTextOperationsToSegments( + segments: readonly TextSegment[], + ops: readonly TextOperation[] +): TextSegment[] { + let next = [...segments]; + + for (const op of ops) { + if (op.type === "insert") { + const index = Math.max(0, Math.min(op.index, textLength(next))); + next = applyInsert(next, index, op.text, op.attributes); + } else if (op.type === "delete") { + const index = Math.max(0, Math.min(op.index, textLength(next))); + const clipped = clipRange(index, op.length, textLength(next)); + next = applyDelete(next, clipped.index, clipped.length).segments; + } else { + const index = Math.max(0, Math.min(op.index, textLength(next))); + const clipped = clipRange(index, op.length, textLength(next)); + next = applyFormat(next, clipped.index, clipped.length, op.attributes); + } + } + + return next; +} + +export function applyLiveTextOperations( + data: LiveTextData, + ops: readonly TextOperation[] +): LiveTextData { + return segmentsToData( + applyTextOperationsToSegments(dataToSegments(data), ops) + ); +} + +/** + * Canonicalizes operations against the document state they are applied to so + * no operation boundary can split a UTF-16 surrogate pair. + * + * Operations are normalized sequentially because each one can change the + * document seen by the operations that follow it. + */ +export function normalizeLiveTextOperations( + data: LiveTextData, + operations: readonly TextOperation[] +): TextOperation[] { + let shadow = dataToSegments(data); + const normalized: TextOperation[] = []; + + for (const operation of operations) { + const text = shadow.map((segment) => segment.text).join(""); + let normalizedOperation: TextOperation; + + if (operation.type === "insert") { + normalizedOperation = { + ...operation, + index: clipIndexToCodePointBoundary(text, operation.index), + }; + } else { + const range = clipRangeToCodePointBoundaries( + text, + operation.index, + operation.length + ); + normalizedOperation = { + ...operation, + index: range.index, + length: range.length, + }; + } + + normalized.push(normalizedOperation); + shadow = applyTextOperationsToSegments(shadow, [normalizedOperation]); + } + + return normalized; +} + +export function invertTextOperations( + segments: readonly TextSegment[], + ops: readonly TextOperation[] +): TextOperation[] { + let shadow = [...segments]; + const reverse: TextOperation[] = []; + + for (const op of ops) { + if (op.type === "insert") { + shadow = applyInsert(shadow, op.index, op.text, op.attributes); + reverse.unshift({ + type: "delete", + index: op.index, + length: op.text.length, + }); + } else if (op.type === "delete") { + const deletedSegments = extractDeletedSegments( + shadow, + op.index, + op.length + ); + shadow = applyDelete(shadow, op.index, op.length).segments; + const inserts: TextOperation[] = []; + let insertIndex = op.index; + for (const segment of deletedSegments) { + inserts.push({ + type: "insert", + index: insertIndex, + text: segment.text, + attributes: segment.attributes, + }); + insertIndex += segment.text.length; + } + for (let index = inserts.length - 1; index >= 0; index--) { + reverse.unshift(inserts[index]); + } + } else { + const inverse = formatReverseOperations( + shadow, + op.index, + op.length, + op.attributes + ); + shadow = applyFormat(shadow, op.index, op.length, op.attributes); + reverse.unshift(...inverse.reverse()); + } + } + + return reverse; +} diff --git a/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts b/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts index 43be9f4a4a5..1cc5f763972 100644 --- a/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts +++ b/packages/liveblocks-core/src/crdts/liveblocks-helpers.ts @@ -2,7 +2,7 @@ import { assertNever, nn } from "../lib/assert"; import type { Json } from "../lib/Json"; import { stringifyOrLog as stringify } from "../lib/stringify"; import { deepClone, entries } from "../lib/utils"; -import type { CreateOp, Op } from "../protocol/Op"; +import type { CreateOp, LiveTextData, Op, TextOperation } from "../protocol/Op"; import { OpCode } from "../protocol/Op"; import type { NodeMap, @@ -17,6 +17,7 @@ import { isMapStorageNode, isObjectStorageNode, isRegisterStorageNode, + isTextStorageNode, } from "../protocol/StorageNode"; import type { ParentToChildNodeMap } from "../types/NodeMap"; import { createManagedPool, type ManagedPool } from "./AbstractCrdt"; @@ -25,8 +26,10 @@ import { LiveList, type LiveListUpdates } from "./LiveList"; import { LiveMap, type LiveMapUpdates } from "./LiveMap"; import { LiveObject, type LiveObjectUpdates } from "./LiveObject"; import { LiveRegister } from "./LiveRegister"; +import { LiveText, type LiveTextUpdates } from "./LiveText"; import type { LiveNode, LiveStructure, Lson, LsonObject } from "./Lson"; -import type { StorageUpdate } from "./StorageUpdates"; +import type { StorageUpdate, UpdateSource } from "./StorageUpdates"; +import { LOCAL_EDIT, REMOTE } from "./StorageUpdates"; export function creationOpToLiveNode(op: CreateOp): LiveNode { return lsonToLiveNode(creationOpToLson(op)); @@ -44,6 +47,8 @@ export function creationOpToLson(op: CreateOp): Lson { return new LiveMap(); case OpCode.CREATE_LIST: return new LiveList([]); + case OpCode.CREATE_TEXT: + return new LiveText(op.data, op.version); default: return assertNever(op, "Unknown creation Op"); } @@ -99,6 +104,8 @@ export function deserialize( return LiveMap._deserialize(node, parentToChildren, pool); } else if (isRegisterStorageNode(node)) { return LiveRegister._deserialize(node, parentToChildren, pool); + } else if (isTextStorageNode(node)) { + return LiveText._deserialize(node, parentToChildren, pool); } else if (isFileStorageNode(node)) { return LiveFile._deserialize(node, parentToChildren, pool); } else { @@ -119,6 +126,8 @@ export function deserializeToLson( return LiveMap._deserialize(node, parentToChildren, pool); } else if (isRegisterStorageNode(node)) { return node[1].data; + } else if (isTextStorageNode(node)) { + return LiveText._deserialize(node, parentToChildren, pool); } else if (isFileStorageNode(node)) { return LiveFile._deserialize(node, parentToChildren, pool); } else { @@ -131,6 +140,7 @@ export function isLiveStructure(value: unknown): value is LiveStructure { isLiveList(value) || isLiveMap(value) || isLiveObject(value) || + isLiveText(value) || isLiveFile(value) ); } @@ -151,6 +161,10 @@ export function isLiveObject(value: unknown): value is LiveObject { return value instanceof LiveObject; } +export function isLiveText(value: unknown): value is LiveText { + return value instanceof LiveText; +} + export function isLiveFile(value: unknown): value is LiveFile { return value instanceof LiveFile; } @@ -174,6 +188,7 @@ export function liveNodeToLson(obj: LiveNode): Lson { obj instanceof LiveList || obj instanceof LiveMap || obj instanceof LiveObject || + obj instanceof LiveText || obj instanceof LiveFile ) { return obj; @@ -187,6 +202,7 @@ export function lsonToLiveNode(value: Lson): LiveNode { value instanceof LiveObject || value instanceof LiveMap || value instanceof LiveList || + value instanceof LiveText || value instanceof LiveFile ) { return value; @@ -315,7 +331,47 @@ export function isJsonEq(a: Json | undefined, b: Json | undefined): boolean { * - UPDATE_OBJECT for "root" (data changed: a: 1 → 99) * - CREATE_OBJECT for "node2" (added) */ -export function diffNodeMap(prev: NodeMap, next: NodeMap): Op[] { +export type DiffNodeMapOptions = { + /** + * Whether existing LiveText nodes should be reconciled through UPDATE_TEXT + * operations. Authoritative storage loads leave this disabled and resync the + * nodes directly; user-initiated restores enable it so the change advances + * the current LiveText timeline. + */ + includeLiveTextUpdates?: boolean; +}; + +function liveTextDataToReplaceOps( + before: LiveTextData, + after: LiveTextData +): TextOperation[] { + const ops: TextOperation[] = []; + const beforeLength = before.reduce( + (length, [text]) => length + text.length, + 0 + ); + + if (beforeLength > 0) { + ops.push({ type: "delete", index: 0, length: beforeLength }); + } + + let index = 0; + for (const [text, attributes] of after) { + if (text.length === 0) { + continue; + } + ops.push({ type: "insert", index, text, attributes }); + index += text.length; + } + + return ops; +} + +export function diffNodeMap( + prev: NodeMap, + next: NodeMap, + options?: DiffNodeMapOptions +): Op[] { const ops: Op[] = []; const idsToRecreate = new Set(); @@ -438,6 +494,16 @@ export function diffNodeMap(prev: NodeMap, next: NodeMap): Op[] { parentKey: crdt.parentKey, }); break; + case CrdtType.TEXT: + ops.push({ + type: OpCode.CREATE_TEXT, + id, + parentId: crdt.parentId, + parentKey: crdt.parentKey, + data: crdt.data, + version: crdt.version, + }); + break; } } @@ -473,6 +539,27 @@ export function diffNodeMap(prev: NodeMap, next: NodeMap): Op[] { } } } + // NOTE: CrdtType.TEXT nodes that exist on both sides are deliberately + // NOT diffed into UPDATE_TEXT ops here. The UPDATE_TEXT op path + // carries pending-op transformation semantics that don't apply to + // authoritative snapshots; LiveText nodes are reconciled against the + // snapshot directly (see LiveText._resyncText, called from the room). + if ( + options?.includeLiveTextUpdates === true && + crdt.type === CrdtType.TEXT && + currentCrdt.type === CrdtType.TEXT && + !isJsonEq(crdt.data, currentCrdt.data) + ) { + ops.push({ + type: OpCode.UPDATE_TEXT, + id, + // A restore is a new edit in the current timeline. The version from + // the historic snapshot describes its old timeline and must not move + // this node's current version backwards. + baseVersion: currentCrdt.version, + ops: liveTextDataToReplaceOps(currentCrdt.data, crdt.data), + }); + } if (crdt.parentKey !== currentCrdt.parentKey) { ops.push({ type: OpCode.SET_PARENT_KEY, @@ -481,6 +568,7 @@ export function diffNodeMap(prev: NodeMap, next: NodeMap): Op[] { }); } } else { + // new Crdt emitCreate(id, crdt); } }); @@ -527,6 +615,33 @@ function mergeListStorageUpdates( }; } +function mergeTextStorageUpdates( + first: LiveTextUpdates, + second: LiveTextUpdates +): LiveTextUpdates { + return { + ...second, + updates: first.updates.concat(second.updates), + }; +} + +function mergeUpdateSources( + first: UpdateSource, + second: UpdateSource +): UpdateSource { + // Any remote change in the mix makes the merged update remote: it no longer + // describes a change this client made on its own. + if (first.origin === "remote" || second.origin === "remote") { + return REMOTE; + } + + // Undo/redo replays are more specific than plain edits, so they win. When + // both are replays, the later one describes the merged update best. + if (second.via !== "edit") return second; + if (first.via !== "edit") return first; + return LOCAL_EDIT; +} + export function mergeStorageUpdates( first: StorageUpdate | undefined, second: StorageUpdate @@ -535,15 +650,18 @@ export function mergeStorageUpdates( return second; } + const source = mergeUpdateSources(first.source, second.source); + if (first.type === "LiveObject" && second.type === "LiveObject") { - return mergeObjectStorageUpdates(first, second); + return { ...mergeObjectStorageUpdates(first, second), source }; } else if (first.type === "LiveMap" && second.type === "LiveMap") { - return mergeMapStorageUpdates(first, second); + return { ...mergeMapStorageUpdates(first, second), source }; } else if (first.type === "LiveList" && second.type === "LiveList") { - return mergeListStorageUpdates(first, second); + return { ...mergeListStorageUpdates(first, second), source }; + } else if (first.type === "LiveText" && second.type === "LiveText") { + return { ...mergeTextStorageUpdates(first, second), source }; } else { /* Mismatching merge types. Throw an error here? */ + return { ...second, source }; } - - return second; } diff --git a/packages/liveblocks-core/src/crdts/utils.ts b/packages/liveblocks-core/src/crdts/utils.ts index e08916d4de6..0b5bc159d4c 100644 --- a/packages/liveblocks-core/src/crdts/utils.ts +++ b/packages/liveblocks-core/src/crdts/utils.ts @@ -3,6 +3,7 @@ import { LiveFile } from "./LiveFile"; import { LiveList } from "./LiveList"; import { LiveMap } from "./LiveMap"; import { LiveObject } from "./LiveObject"; +import { LiveText } from "./LiveText"; import type { Lson } from "./Lson"; /** @@ -30,6 +31,12 @@ export function toPlainLson(lson: Lson): PlainLson { liveblocksType: "LiveList", data: [...lson].map((item) => toPlainLson(item)), }; + } else if (lson instanceof LiveText) { + return { + liveblocksType: "LiveText", + data: lson.toJSON(), + version: lson.version, + }; } else if (lson instanceof LiveFile) { return { liveblocksType: "LiveFile", diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts index 02e736659e5..ce3c54d449e 100644 --- a/packages/liveblocks-core/src/index.ts +++ b/packages/liveblocks-core/src/index.ts @@ -75,6 +75,7 @@ export { export type { CreateManagedPoolOptions, ManagedPool, + PrivateLiveNodeApi, } from "./crdts/AbstractCrdt"; export { createManagedPool } from "./crdts/AbstractCrdt"; export { cloneLson, isLiveNode } from "./crdts/liveblocks-helpers"; @@ -87,6 +88,22 @@ export { export { LiveList } from "./crdts/LiveList"; export { LiveMap } from "./crdts/LiveMap"; export { LiveObject } from "./crdts/LiveObject"; +export type { + LiveTextAttributes, + LiveTextAttributesPatch, + LiveTextChange, + LiveTextData, + LiveTextOperation, + LiveTextSegment, + LiveTextUpdates, + PrivateLiveTextApi, +} from "./crdts/LiveText"; +export { LiveText } from "./crdts/LiveText"; +export { + applyLiveTextOperations, + normalizeLiveTextOperations, + transformTextOperations, +} from "./crdts/liveTextOps"; export type { LiveNode, LiveStructure, @@ -99,7 +116,9 @@ export type { LiveListUpdate, LiveMapUpdate, LiveObjectUpdate, + LiveTextUpdate, StorageUpdate, + UpdateSource, } from "./crdts/StorageUpdates"; export { toPlainLson } from "./crdts/utils"; export type { @@ -315,6 +334,7 @@ export type { CreateObjectOp, CreateOp, CreateRegisterOp, + CreateTextOp, DeleteCrdtOp, DeleteObjectKeyOp, HasOpId, @@ -322,7 +342,10 @@ export type { Op, ServerWireOp, SetParentKeyOp, + TextAttributes, + TextOperation, UpdateObjectOp, + UpdateTextOp, } from "./protocol/Op"; export { OpCode } from "./protocol/Op"; export type { @@ -364,6 +387,7 @@ export type { CompactObjectNode, CompactRegisterNode, CompactRootNode, + CompactTextNode, FileStorageNode, ListStorageNode, MapStorageNode, @@ -380,7 +404,9 @@ export type { SerializedObject, SerializedRegister, SerializedRootObject, + SerializedText, StorageNode, + TextStorageNode, } from "./protocol/StorageNode"; export { compactNodesToNodeStream, @@ -391,6 +417,7 @@ export { isObjectStorageNode, isRegisterStorageNode, isRootStorageNode, + isTextStorageNode, nodeStreamToCompactNodes, } from "./protocol/StorageNode"; export type { @@ -468,6 +495,7 @@ export type { PlainLsonList, PlainLsonMap, PlainLsonObject, + PlainLsonText, } from "./types/PlainLson"; export type { User } from "./types/User"; export { detectDupes }; diff --git a/packages/liveblocks-core/src/protocol/Op.ts b/packages/liveblocks-core/src/protocol/Op.ts index 5547ecc262e..fe8c7425ba9 100644 --- a/packages/liveblocks-core/src/protocol/Op.ts +++ b/packages/liveblocks-core/src/protocol/Op.ts @@ -13,7 +13,8 @@ export const OpCode = Object.freeze({ DELETE_OBJECT_KEY: 6, CREATE_MAP: 7, CREATE_REGISTER: 8, - // TODO: 9 and 10 are used by LiveText, wait until it's merged. + CREATE_TEXT: 9, + UPDATE_TEXT: 10, CREATE_FILE: 11, }); @@ -27,9 +28,53 @@ export namespace OpCode { export type DELETE_OBJECT_KEY = typeof OpCode.DELETE_OBJECT_KEY; export type CREATE_MAP = typeof OpCode.CREATE_MAP; export type CREATE_REGISTER = typeof OpCode.CREATE_REGISTER; + export type CREATE_TEXT = typeof OpCode.CREATE_TEXT; + export type UPDATE_TEXT = typeof OpCode.UPDATE_TEXT; export type CREATE_FILE = typeof OpCode.CREATE_FILE; } +export type TextAttributes = JsonObject; + +/** + * A single segment in a {@link LiveTextData} document. + * + * @example + * ["Hello world"] + * ["Hello ", { bold: true }] + */ +export type LiveTextSegment = + | [text: string] + | [text: string, attributes: TextAttributes]; + +/** + * Serialized form of a {@link LiveText} document: an ordered list of text + * segments with optional inline attributes. + * + * @example + * [["Hello world"]] + * [["Hello ", { bold: true }], ["world"]] + */ +export type LiveTextData = LiveTextSegment[]; + +export type TextOperation = + | { + type: "insert"; + index: number; + text: string; + attributes?: TextAttributes; + } + | { + type: "delete"; + index: number; + length: number; + } + | { + type: "format"; + index: number; + length: number; + attributes: JsonObject; + }; + /** * These operations are the payload for {@link UpdateStorageServerMsg} messages * only. @@ -37,6 +82,7 @@ export namespace OpCode { export type Op = | CreateOp | UpdateObjectOp + | UpdateTextOp | DeleteCrdtOp | SetParentKeyOp // Only for lists! | DeleteObjectKeyOp; @@ -46,6 +92,7 @@ export type CreateOp = | CreateRegisterOp | CreateMapOp | CreateListOp + | CreateTextOp | CreateFileOp; export type UpdateObjectOp = { @@ -97,6 +144,18 @@ export type CreateRegisterOp = { readonly deletedId?: string; }; +export type CreateTextOp = { + readonly opId?: string; + readonly id: string; + readonly type: OpCode.CREATE_TEXT; + readonly parentId: string; + readonly parentKey: string; + readonly data: LiveTextData; + readonly version: number; + readonly intent?: "set" | "push"; + readonly deletedId?: string; +}; + export type CreateFileOp = { readonly opId?: string; readonly id: string; @@ -108,6 +167,15 @@ export type CreateFileOp = { readonly deletedId?: string; }; +export type UpdateTextOp = { + readonly opId?: string; + readonly id: string; + readonly type: OpCode.UPDATE_TEXT; + readonly baseVersion: number; + readonly version?: number; + readonly ops: TextOperation[]; +}; + export type DeleteCrdtOp = { readonly opId?: string; readonly id: string; @@ -137,7 +205,8 @@ export function isCreateOp(op: O): op is O & CreateOp { op.type === OpCode.CREATE_REGISTER || op.type === OpCode.CREATE_FILE || op.type === OpCode.CREATE_MAP || - op.type === OpCode.CREATE_LIST + op.type === OpCode.CREATE_LIST || + op.type === OpCode.CREATE_TEXT ); } diff --git a/packages/liveblocks-core/src/protocol/StorageNode.ts b/packages/liveblocks-core/src/protocol/StorageNode.ts index 0b9c6bfaaca..f3dd48e5d58 100644 --- a/packages/liveblocks-core/src/protocol/StorageNode.ts +++ b/packages/liveblocks-core/src/protocol/StorageNode.ts @@ -1,4 +1,5 @@ import type { Json, JsonObject } from "../lib/Json"; +import type { LiveTextData } from "./Op"; export type IdTuple = [id: string, value: T]; @@ -8,7 +9,7 @@ export const CrdtType = Object.freeze({ LIST: 1, MAP: 2, REGISTER: 3, - // TODO: 4 is used by LiveText, wait until it's merged. + TEXT: 4, FILE: 5, }); @@ -17,6 +18,7 @@ export namespace CrdtType { export type LIST = typeof CrdtType.LIST; export type MAP = typeof CrdtType.MAP; export type REGISTER = typeof CrdtType.REGISTER; + export type TEXT = typeof CrdtType.TEXT; export type FILE = typeof CrdtType.FILE; } @@ -27,6 +29,7 @@ export type SerializedChild = | SerializedList | SerializedMap | SerializedRegister + | SerializedText | SerializedFile; export type LiveFileData = { @@ -71,6 +74,14 @@ export type SerializedRegister = { readonly data: Json; }; +export type SerializedText = { + readonly type: CrdtType.TEXT; + readonly parentId: string; + readonly parentKey: string; + readonly data: LiveTextData; + readonly version: number; +}; + export type SerializedFile = { readonly type: CrdtType.FILE; readonly parentId: string; @@ -85,6 +96,7 @@ export type ChildStorageNode = | ListStorageNode | MapStorageNode | RegisterStorageNode + | TextStorageNode | FileStorageNode; export type RootStorageNode = [id: "root", value: SerializedRootObject]; @@ -92,6 +104,7 @@ export type ObjectStorageNode = [id: string, value: SerializedObject]; export type ListStorageNode = [id: string, value: SerializedList]; export type MapStorageNode = [id: string, value: SerializedMap]; export type RegisterStorageNode = [id: string, value: SerializedRegister]; +export type TextStorageNode = [id: string, value: SerializedText]; export type FileStorageNode = [id: string, value: SerializedFile]; export type NodeMap = Map; @@ -121,6 +134,10 @@ export function isRegisterStorageNode( return node[1].type === CrdtType.REGISTER; } +export function isTextStorageNode(node: StorageNode): node is TextStorageNode { + return node[1].type === CrdtType.TEXT; +} + export function isFileStorageNode(node: StorageNode): node is FileStorageNode { return node[1].type === CrdtType.FILE; } @@ -132,6 +149,7 @@ export type CompactChildNode = | CompactListNode | CompactMapNode | CompactRegisterNode + | CompactTextNode | CompactFileNode; export type CompactRootNode = readonly [id: "root", data: JsonObject]; @@ -166,6 +184,15 @@ export type CompactRegisterNode = readonly [ data: Json, ]; +export type CompactTextNode = readonly [ + id: string, + type: CrdtType.TEXT, + parentId: string, + parentKey: string, + data: LiveTextData, + version: number, +]; + export type CompactFileNode = readonly [ id: string, type: CrdtType.FILE, @@ -205,6 +232,10 @@ export function* compactNodesToNodeStream( // prettier-ignore yield [cnode[0], {type: CrdtType.REGISTER, parentId: cnode[2], parentKey: cnode[3], data: cnode[4], }]; break; + case CrdtType.TEXT: + // prettier-ignore + yield [cnode[0], { type: CrdtType.TEXT, parentId: cnode[2], parentKey: cnode[3], data: cnode[4], version: cnode[5] }]; + break; case CrdtType.FILE: // prettier-ignore yield [cnode[0], {type: CrdtType.FILE, parentId: cnode[2], parentKey: cnode[3], data: cnode[4], }]; @@ -241,6 +272,17 @@ export function* nodeStreamToCompactNodes( const id = node[0]; const crdt = node[1]; yield [id, CrdtType.REGISTER, crdt.parentId, crdt.parentKey, crdt.data]; + } else if (isTextStorageNode(node)) { + const id = node[0]; + const crdt = node[1]; + yield [ + id, + CrdtType.TEXT, + crdt.parentId, + crdt.parentKey, + crdt.data, + crdt.version, + ]; } else if (isFileStorageNode(node)) { const id = node[0]; const crdt = node[1]; diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 3005391fb95..4e2916f55f6 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -5,14 +5,19 @@ import { injectBrandBadge } from "./brand"; import type { InternalSyncStatus } from "./client"; import type { Delegates, LostConnectionEvent, Status } from "./connection"; import { ManagedSocket, StopRetrying } from "./connection"; -import type { ApplyResult, ManagedPool } from "./crdts/AbstractCrdt"; -import { createManagedPool, OpSource } from "./crdts/AbstractCrdt"; +import type { + ApplyResult, + DispatchOptions, + ManagedPool, +} from "./crdts/AbstractCrdt"; +import { createManagedPool } from "./crdts/AbstractCrdt"; import { cloneLson, diffNodeMap, dumpPool, isLiveList, isLiveNode, + isLiveText, isSameNodeOrChildOf, liveObjectFromNodeStream, mergeStorageUpdates, @@ -21,7 +26,20 @@ import type { LiveFile, LiveFileReference } from "./crdts/LiveFile"; import { getLiveFileId } from "./crdts/LiveFile"; import { LiveObject } from "./crdts/LiveObject"; import type { LiveStructure, LsonObject } from "./crdts/Lson"; -import type { StorageCallback, StorageUpdate } from "./crdts/StorageUpdates"; +import type { + OpSource, + StorageCallback, + StorageUpdate, + UpdateSource, + Via, +} from "./crdts/StorageUpdates"; +import { + LOCAL_EDIT, + LOCAL_REDO, + LOCAL_UNDO, + REMOTE, + toUpdateSource, +} from "./crdts/StorageUpdates"; import { UnacknowledgedOps } from "./crdts/UnacknowledgedOps"; import type { DCM, @@ -102,7 +120,7 @@ import type { } from "./protocol/InboxNotifications"; import type { MentionData } from "./protocol/MentionData"; import type { ClientWireOp, Op, ServerWireOp } from "./protocol/Op"; -import { isIgnoredOp, OpCode } from "./protocol/Op"; +import { isCreateOp, isIgnoredOp, OpCode } from "./protocol/Op"; import type { RoomSubscriptionSettings } from "./protocol/RoomSubscriptionSettings"; import type { CommentsEventServerMsg, @@ -128,7 +146,7 @@ import type { SerializedCrdt, SerializedRootObject, } from "./protocol/StorageNode"; -import { compactNodesToNodeStream } from "./protocol/StorageNode"; +import { compactNodesToNodeStream, CrdtType } from "./protocol/StorageNode"; import type { SubscriptionData, SubscriptionDeleteInfo, @@ -344,6 +362,14 @@ export type HistoryEvent = { canRedo: boolean; }; +/** @internal */ +export type PrivateHistoryEvent = + | { action: "push"; id: number } + | { action: "undo"; id: number } + | { action: "redo"; id: number } + | { action: "clear" } + | { action: "discard"; ids: number[] }; + export type RoomEventName = Extract< keyof RoomEventCallbackMap, string @@ -1263,7 +1289,14 @@ export interface SyncSource { export type PrivateRoomApi = { // For introspection in unit tests only presenceBuffer: Json | undefined; - undoStack: readonly (readonly Readonly>[])[]; + undoStack: readonly { + readonly id: number; + readonly frames: readonly Readonly>[]; + }[]; + redoStack: readonly { + readonly id: number; + readonly frames: readonly Readonly>[]; + }[]; nodeCount: number; // Get/set the associated Yjs provider on this room @@ -1322,6 +1355,13 @@ export type PrivateRoomApi = { attachmentUrlsStore: BatchStore; fileUrlsStore: BatchStore; + readonly history: Observable< + | { action: "push"; id: number } + | { action: "undo"; id: number } + | { action: "redo"; id: number } + | { action: "clear" } + | { action: "discard"; ids: number[] } + >; }; function connectionAccessFromScopes(scopes: string[]): { @@ -1348,6 +1388,11 @@ type PresenceStackframe

    = { readonly data: P; }; +type HistoryStackItem

    = { + id: number; + frames: Stackframe

    []; +}; + type IdFactory = () => string; export type StaticSessionInfo = { @@ -1409,8 +1454,8 @@ type RoomState< pool: ManagedPool; root: LiveObject | undefined; - undoStack: Stackframe

    [][]; - redoStack: Stackframe

    [][]; + undoStack: HistoryStackItem

    []; + redoStack: HistoryStackItem

    []; /** * When history is paused, all operations will get queued up here. When @@ -1435,6 +1480,10 @@ type RoomState< // history must wait until after the batch’s `reverseOps` are merged // otherwise those ops become a second undo step. scheduleHistoryResume: boolean; + + // LiveText can dispatch with empty `ops` while UPDATE_TEXT is in-flight + // (queued edits) but still request redo clearing via DispatchOptions. + clearRedoStack?: boolean; } | null; // A registry of yet-unacknowledged Ops. These Ops have already been @@ -1699,6 +1748,10 @@ export function createRoom< unacknowledgedOps, }; + let nextHistoryItemId = 0; + // Depth counter for nested history.disable() calls, 0 means history is not disabled + let historyDisabled = 0; + // Accumulates nodes as initial storage arrives in chunks via // STORAGE_CHUNK messages. Once the final chunk arrives (with // done: true), the complete map is passed to processInitialStorage(). @@ -1826,7 +1879,8 @@ export function createRoom< function onDispatch( ops: ClientWireOp[], reverse: Op[], - storageUpdates: Map + storageUpdates: Map, + options?: DispatchOptions ): void { if (context.activeBatch) { for (const op of ops) { @@ -1842,12 +1896,20 @@ export function createRoom< ); } context.activeBatch.reverseOps.pushLeft(reverse); + // LiveText may dispatch with empty `ops` while an UPDATE_TEXT is + // in-flight (queued edits), but still pass `clearRedoStack: true`. + // Honor that here — the batch finally-block only sees ops.length. + if (options?.clearRedoStack) { + context.activeBatch.clearRedoStack = true; + } } else { if (reverse.length > 0) { addToUndoStack(reverse); } + if (options?.clearRedoStack ?? ops.length > 0) { + clearRedoStack(); + } if (ops.length > 0) { - context.redoStack.length = 0; dispatchOps(ops); } notify({ storageUpdates }); @@ -1873,6 +1935,7 @@ export function createRoom< others: makeEventSource>(), storageBatch: makeEventSource(), history: makeEventSource(), + privateHistory: makeEventSource(), storageDidLoad: makeEventSource(), storageStatus: makeEventSource(), ydoc: makeEventSource(), @@ -1989,12 +2052,15 @@ export function createRoom< // Serializes the current live Storage into a NodeMap and diffs it against // `target`, returning the ops that make the live tree match `target`. Shared // by storage load (applied remotely) and restore (applied locally). - function diffCurrentStorageAgainst(target: NodeMap): Op[] { + function diffCurrentStorageAgainst( + target: NodeMap, + options?: { includeLiveTextUpdates?: boolean } + ): Op[] { const current: NodeMap = new Map(); for (const [id, crdt] of context.pool.nodes) { current.set(id, crdt._serialize()); } - return diffNodeMap(current, target); + return diffNodeMap(current, target, options); } function createOrUpdateRootFromMessage(nodes: NodeMap) { @@ -2004,6 +2070,32 @@ export function createRoom< if (context.root !== undefined) { const result = applyRemoteOps(diffCurrentStorageAgainst(nodes)); + + // LiveText nodes are not covered by the op diff above (their op path + // carries pending-op transformation semantics that don't apply to + // authoritative snapshots). Reconcile them against the snapshot + // directly; locally pending text ops are preserved on top and re-sent + // by the offline-ops replay. + for (const [id, crdt] of nodes) { + if (crdt.type === CrdtType.TEXT) { + const node = context.pool.nodes.get(id); + if (node !== undefined && isLiveText(node)) { + // An authoritative snapshot from the server, so whatever it + // changes locally is a remote change as far as subscribers go. + const update = node._resyncText(crdt.data, crdt.version, REMOTE); + if (update !== undefined) { + result.updates.storageUpdates.set( + id, + mergeStorageUpdates( + result.updates.storageUpdates.get(id), + update + ) + ); + } + } + } + } + notify(result.updates); } else { context.root = LiveObject._fromItems( @@ -2032,6 +2124,18 @@ export function createRoom< }); } + function notifyPrivateHistory(event: PrivateHistoryEvent) { + if (historyDisabled > 0) return; + eventHub.privateHistory.notify(event); + } + + function clearRedoStack() { + if (context.redoStack.length === 0) return; + const ids = context.redoStack.map((item) => item.id); + context.redoStack.length = 0; + notifyPrivateHistory({ action: "discard", ids }); + } + /** * Reconciles the live Storage so it matches the given target nodes (e.g. a * historic version snapshot): diffs them against the current state and applies @@ -2048,7 +2152,8 @@ export function createRoom< } const ops = diffCurrentStorageAgainst( - new Map(nodes) + new Map(nodes), + { includeLiveTextUpdates: true } ); if (ops.length === 0) { return; // Already identical -- nothing to do. @@ -2072,10 +2177,15 @@ export function createRoom< function _addToRealUndoStack(frames: Stackframe

    []) { // If undo stack is too large, we remove the older item if (context.undoStack.length >= 50) { - context.undoStack.shift(); + const evicted = context.undoStack.shift(); + if (evicted !== undefined) { + notifyPrivateHistory({ action: "discard", ids: [evicted.id] }); + } } - context.undoStack.push(frames); + const id = nextHistoryItemId++; + context.undoStack.push({ id, frames }); + notifyPrivateHistory({ action: "push", id }); onHistoryChange(); } @@ -2110,7 +2220,12 @@ export function createRoom< } if (storageUpdates !== undefined && storageUpdates.size > 0) { - const updates = Array.from(storageUpdates.values()); + // This is the only place Storage updates reach subscribers, so it's + // where the internal `optimistic` flag gets dropped. See OpSource. + const updates = Array.from(storageUpdates.values(), (update) => ({ + ...update, + source: toUpdateSource(update.source), + })); eventHub.storageBatch.notify(updates); } notifyStorageStatus(); @@ -2127,7 +2242,26 @@ export function createRoom< ); } - function applyLocalOps(frames: readonly Stackframe

    []): { + /** + * How each still-unacknowledged op was made, for the ops where that isn't a + * plain edit. Lets an ack be reported with the same `via` as the change it + * confirms, instead of every ack looking like a fresh edit. + */ + const viaByOpId = new Map(); + + function viaOfAckedOp(opId: string): Via { + const via = viaByOpId.get(opId); + if (via === undefined) { + return "edit"; + } + viaByOpId.delete(opId); + return via; + } + + function applyLocalOps( + frames: readonly Stackframe

    [], + localSource: Extract = LOCAL_EDIT + ): { opsToEmit: ClientWireOp[]; // Ops to send over the wire afterwards reverse: Stackframe

    []; // Reverse ops to add to the undo stack aftwards // Updates to notify about afterwards @@ -2141,17 +2275,89 @@ export function createRoom< (f): f is PresenceStackframe

    => f.type === "presence" ); + // Restoring a detached LiveText starts a new server-side text timeline. + // Reusing the old ID could let operations from the deleted timeline apply + // to this new lifetime without being rebased. Ops replayed after reconnect + // already have opIds and must keep their original IDs. + const restoredTextIds = new Map(); + for (const op of ops) { + if ( + op.type === OpCode.CREATE_TEXT && + op.opId === undefined && + context.pool.nodes.get(op.id) === undefined && + !restoredTextIds.has(op.id) + ) { + restoredTextIds.set(op.id, context.pool.generateId()); + } + } + + const remappedOps = + restoredTextIds.size === 0 + ? ops + : ops.map((op): Op => { + if (op.opId !== undefined) { + return op; + } + + const id = restoredTextIds.get(op.id); + + if (isCreateOp(op)) { + const parentId = restoredTextIds.get(op.parentId); + const deletedId = + op.deletedId === undefined + ? undefined + : restoredTextIds.get(op.deletedId); + + if ( + id === undefined && + parentId === undefined && + deletedId === undefined + ) { + return op; + } + + if (op.type === OpCode.CREATE_TEXT && id !== undefined) { + return { + ...op, + id, + version: 0, + ...(parentId === undefined ? {} : { parentId }), + ...(deletedId === undefined ? {} : { deletedId }), + }; + } + + return { + ...op, + ...(id === undefined ? {} : { id }), + ...(parentId === undefined ? {} : { parentId }), + ...(deletedId === undefined ? {} : { deletedId }), + }; + } + + return id === undefined ? op : { ...op, id }; + }); + // Ensure all local ops have opIds assigned before applying them - const opsWithOpIds = ops.map((op: Op) => + const opsWithOpIds = remappedOps.map((op: Op) => op.opId === undefined ? { ...op, opId: context.pool.generateOpId() } : (op as ClientWireOp) ); + // Remember how these ops came about, so that when the server acks them we + // can report the ack the same way. Only history replays are recorded; a + // plain edit is what an unrecorded opId means (see viaOfAckedOp). + if (localSource.via !== "edit") { + for (const op of opsWithOpIds) { + viaByOpId.set(op.opId, localSource.via); + } + } + const { reverse, updates } = applyOps( pframes, opsWithOpIds, - /* isLocal */ true + /* isLocal */ true, + localSource ); return { opsToEmit: opsWithOpIds, reverse, updates }; } @@ -2169,7 +2375,8 @@ export function createRoom< function applyOps( pframes: readonly PresenceStackframe

    [], ops: readonly Op[], - isLocal: boolean + isLocal: boolean, + localSource: Extract = LOCAL_EDIT ): { reverse: Stackframe

    []; updates: { @@ -2214,14 +2421,20 @@ export function createRoom< let source: OpSource; if (isLocal) { - source = OpSource.LOCAL; + source = { ...localSource, optimistic: true }; } else if (op.opId !== undefined) { context.unacknowledgedOps.delete(op.opId); - source = OpSource.OURS; + // The server echoing back our own op. It describes a change this + // client made, now confirmed, so it keeps the `via` it was made with. + source = { + origin: "local", + via: viaOfAckedOp(op.opId), + optimistic: false, + }; } else { // Remotely generated Ops (and fix Ops as a special case of that) // don't have opId anymore. - source = OpSource.THEIRS; + source = REMOTE; } const applyOpResult = applyOp(op, source); @@ -2246,6 +2459,7 @@ export function createRoom< op.type === OpCode.CREATE_LIST || op.type === OpCode.CREATE_MAP || op.type === OpCode.CREATE_OBJECT || + op.type === OpCode.CREATE_TEXT || op.type === OpCode.CREATE_FILE ) { createdNodeIds.add(op.id); @@ -2271,13 +2485,14 @@ export function createRoom< switch (op.type) { case OpCode.DELETE_OBJECT_KEY: case OpCode.UPDATE_OBJECT: + case OpCode.UPDATE_TEXT: case OpCode.DELETE_CRDT: { const node = context.pool.nodes.get(op.id); if (node === undefined) { return { modified: false }; } - return node._apply(op, source === OpSource.LOCAL); + return node._apply(op, source); } case OpCode.SET_PARENT_KEY: { @@ -2298,6 +2513,7 @@ export function createRoom< case OpCode.CREATE_OBJECT: case OpCode.CREATE_LIST: case OpCode.CREATE_MAP: + case OpCode.CREATE_TEXT: case OpCode.CREATE_FILE: case OpCode.CREATE_REGISTER: { if (op.parentId === undefined) { @@ -2635,17 +2851,45 @@ export function createRoom< break; } - // Receiving a RejectedOps message in the client means that the server is no - // longer in sync with the client. Trying to synchronize the client again by - // rolling back particular Ops may be hard/impossible. It's fine to not try and - // accept the out-of-sync reality and throw an error. + // Receiving a RejectedOps message means the server refused some of + // our ops, so our optimistic local state is out of sync with the + // server. For LiveText ops this is a normal (if rare) situation — + // e.g. a client that was offline long enough to fall outside the + // server's retained history window — and we can recover: drop the + // rejected pending state and re-fetch the authoritative storage + // snapshot. For other ops (e.g. permission rejections), rolling back + // particular Ops is hard/impossible, so we keep the old behavior of + // accepting the out-of-sync reality and surfacing an error. case ServerMsgCode.REJECT_STORAGE_OP: { console.errorWithTitle( "Storage mutation rejection error", message.reason ); - if (process.env.NODE_ENV !== "production") { + let needsStorageResync = false; + for (const opId of message.opIds) { + const rejectedOp = context.unacknowledgedOps.get(opId); + context.unacknowledgedOps.delete(opId); + context.buffer.storageOperations = + context.buffer.storageOperations.filter((op) => op.opId !== opId); + viaByOpId.delete(opId); + + if ( + rejectedOp !== undefined && + rejectedOp.type === OpCode.UPDATE_TEXT + ) { + const node = context.pool.nodes.get(rejectedOp.id); + if (node !== undefined && isLiveText(node)) { + node._rejectPendingOp(opId); + needsStorageResync = true; + } + } + } + + if (needsStorageResync) { + refreshStorage(); + flushNowOrSoon(); + } else if (process.env.NODE_ENV !== "production") { throw new Error( `Storage mutations rejected by server: ${message.reason}` ); @@ -3396,16 +3640,17 @@ export function createRoom< if (context.activeBatch) { throw new Error("undo is not allowed during a batch"); } - const frames = context.undoStack.pop(); - if (frames === undefined) { + const item = context.undoStack.pop(); + if (item === undefined) { return; } context.pausedHistory = null; - const result = applyLocalOps(frames); + const result = applyLocalOps(item.frames, LOCAL_UNDO); + context.redoStack.push({ id: item.id, frames: result.reverse }); + notifyPrivateHistory({ action: "undo", id: item.id }); notify(result.updates); - context.redoStack.push(result.reverse); onHistoryChange(); for (const op of result.opsToEmit) { @@ -3419,16 +3664,17 @@ export function createRoom< throw new Error("redo is not allowed during a batch"); } - const frames = context.redoStack.pop(); - if (frames === undefined) { + const item = context.redoStack.pop(); + if (item === undefined) { return; } context.pausedHistory = null; - const result = applyLocalOps(frames); + const result = applyLocalOps(item.frames, LOCAL_REDO); + context.undoStack.push({ id: item.id, frames: result.reverse }); + notifyPrivateHistory({ action: "redo", id: item.id }); notify(result.updates); - context.undoStack.push(result.reverse); onHistoryChange(); for (const op of result.opsToEmit) { @@ -3440,6 +3686,8 @@ export function createRoom< function clear() { context.undoStack.length = 0; context.redoStack.length = 0; + notifyPrivateHistory({ action: "clear" }); + onHistoryChange(); } function batch(callback: () => T): T { @@ -3478,10 +3726,11 @@ export function createRoom< commitPausedHistoryToUndoStack(); } - if (currentBatch.ops.length > 0) { - // Only clear the redo stack if something has changed during a batch - // Clear the redo stack because batch is always called from a local operation - context.redoStack.length = 0; + if (currentBatch.ops.length > 0 || currentBatch.clearRedoStack) { + // Clear redo when the batch mutated storage, or when a nested + // dispatch explicitly requested it (e.g. LiveText queued edits + // with empty `ops` but `clearRedoStack: true`). + clearRedoStack(); } if (currentBatch.ops.length > 0) { @@ -3517,14 +3766,11 @@ export function createRoom< commitPausedHistoryToUndoStack(); } - // Depth counter for nested history.disable() calls, 0 means history is not disabled - let historyDisabled = 0; - function disableHistory(fn: () => T): T { const origUndo = context.undoStack; const origRedo = context.redoStack; - const tempUndo: Stackframe

    [][] = []; - const tempRedo: Stackframe

    [][] = []; + const tempUndo: HistoryStackItem

    [] = []; + const tempRedo: HistoryStackItem

    [] = []; context.undoStack = tempUndo; context.redoStack = tempRedo; historyDisabled++; @@ -3890,9 +4136,26 @@ export function createRoom< { [kInternal]: { get presenceBuffer() { return deepClone(context.buffer.presenceUpdates?.data ?? null) }, // prettier-ignore - get undoStack() { return deepClone(context.undoStack) }, // prettier-ignore + get undoStack() { + return structuredClone( + context.undoStack.map((item) => ({ + id: item.id, + frames: item.frames, + })) + ); + }, // prettier-ignore + get redoStack() { + return structuredClone( + context.redoStack.map((item) => ({ + id: item.id, + frames: item.frames, + })) + ); + }, // prettier-ignore get nodeCount() { return context.pool.nodes.size }, // prettier-ignore + history: eventHub.privateHistory.observable, + getYjsProvider() { return context.yjsProvider; }, diff --git a/packages/liveblocks-core/src/types/PlainLson.ts b/packages/liveblocks-core/src/types/PlainLson.ts index 565916034ba..2d7d06f8888 100644 --- a/packages/liveblocks-core/src/types/PlainLson.ts +++ b/packages/liveblocks-core/src/types/PlainLson.ts @@ -41,6 +41,7 @@ */ import type { Json } from "../lib/Json"; +import type { LiveTextData } from "../protocol/Op"; import type { LiveFileData } from "../protocol/StorageNode"; export type PlainLsonFields = Record; @@ -60,6 +61,12 @@ export type PlainLsonList = { data: PlainLson[]; }; +export type PlainLsonText = { + liveblocksType: "LiveText"; + data: LiveTextData; + version?: number; +}; + export type PlainLsonFile = { liveblocksType: "LiveFile"; data: LiveFileData; @@ -69,6 +76,7 @@ export type PlainLson = | PlainLsonObject | PlainLsonMap | PlainLsonList + | PlainLsonText | PlainLsonFile // Any "normal" Json value, as long as it's not an object with diff --git a/packages/liveblocks-core/test-d/ToJson.test-d.ts b/packages/liveblocks-core/test-d/ToJson.test-d.ts index d72a434520e..d4ec989c275 100644 --- a/packages/liveblocks-core/test-d/ToJson.test-d.ts +++ b/packages/liveblocks-core/test-d/ToJson.test-d.ts @@ -1,10 +1,11 @@ import type { Lson, LsonObject, + LiveTextData, ReadonlyJsonObject, ToJson, } from "@liveblocks/core"; -import { LiveList, LiveMap, LiveObject } from "@liveblocks/core"; +import { LiveList, LiveMap, LiveObject, LiveText } from "@liveblocks/core"; import { describe, expectTypeOf, test } from "vitest"; declare const str: string; @@ -152,6 +153,13 @@ describe("ToJson", () => { }>(); }); + // --------------------------------------------------------------------------- + // LiveText + // --------------------------------------------------------------------------- + test("LiveText", () => { + expectTypeOf(toJson(new LiveText("hello"))).toEqualTypeOf(); + }); + // --------------------------------------------------------------------------- // Unions involving Live types // --------------------------------------------------------------------------- diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 2022039d33e..9ae3af7b2bc 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.23.1", + "version": "3.24.0", "description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-lexical/.gitignore b/packages/liveblocks-lexical/.gitignore new file mode 100644 index 00000000000..5e4eff3eb02 --- /dev/null +++ b/packages/liveblocks-lexical/.gitignore @@ -0,0 +1,5 @@ +/scripts/*.js +**/*.css +**/*.css.map +!/src/**/*.css +!/src/**/*.css.map diff --git a/packages/liveblocks-lexical/.stylelintrc.cjs b/packages/liveblocks-lexical/.stylelintrc.cjs new file mode 100644 index 00000000000..0568d5a0d4d --- /dev/null +++ b/packages/liveblocks-lexical/.stylelintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ["stylelint-config-standard"], + rules: { + "custom-property-pattern": /^lb-[a-z-]+$/, + "selector-class-pattern": /^lb-[a-z-:]+$/, + }, +}; diff --git a/packages/liveblocks-lexical/README.md b/packages/liveblocks-lexical/README.md new file mode 100644 index 00000000000..32653acb867 --- /dev/null +++ b/packages/liveblocks-lexical/README.md @@ -0,0 +1,56 @@ +

    + Liveblocks + Liveblocks +

    + +# `@liveblocks/lexical` + +

    + NPM + Size + License +

    + +`@liveblocks/lexical` provides APIs to integrate [Lexical](https://lexical.dev/) +text editors with Liveblocks—a platform to build, host, and scale collaborative +applications with zero configuration, no maintenance required. + +## Installation + +``` +npm install @liveblocks/client @liveblocks/react @liveblocks/lexical lexical @lexical/react @lexical/selection @lexical/utils +``` + +## Documentation + +Read the +[documentation](https://liveblocks.io/docs/api-reference/liveblocks-lexical) +for guides and API references. + +## Examples + +Explore our [collaborative examples](https://liveblocks.io/examples) to help you +get started. + +> All examples are open-source and live in this repository, within +> [`/examples`](../../examples). + +## Releases + +See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or +learn more about +[upcoming releases](https://github.com/liveblocks/liveblocks/milestones). + +## Community + +- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks + community, ask questions and share tips. +- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts, + and general Liveblocks tips. + +## License + +Licensed under the Apache License 2.0, Copyright © 2021-present +[Liveblocks](https://liveblocks.io). + +See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-lexical/eslint.config.mjs b/packages/liveblocks-lexical/eslint.config.mjs new file mode 100644 index 00000000000..9ce94f64b0a --- /dev/null +++ b/packages/liveblocks-lexical/eslint.config.mjs @@ -0,0 +1,74 @@ +import { makeConfig } from "@liveblocks/eslint-config"; +import commonRestrictedSyntax from "@liveblocks/eslint-config/restricted-syntax"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default [ + ...makeConfig(), + + { + plugins: { + react, + "react-hooks": reactHooks, + }, + + settings: { + react: { + version: "detect", + }, + }, + + rules: { + // ------------------------------- + // Custom syntax we want to forbid + // ------------------------------- + "no-restricted-syntax": [ + "error", + ...commonRestrictedSyntax, + { + selector: + "ImportDeclaration[source.value='react'] ImportSpecifier[imported.name='use']", + message: "use is only available on React >=19.", + }, + ], + + // ---------------------------------------------------------------------- + // Overrides from default rule config used in all other projects! + // ---------------------------------------------------------------------- + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/unbound-method": "off", + + // ---------------------------------------------------------------------- + // Extra rules for this project specifically + // ---------------------------------------------------------------------- + + // Enforce React best practices + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + "react/jsx-key": ["error", { checkFragmentShorthand: true }], + "react/no-unescaped-entities": "error", + "react/no-unknown-property": "error", + + // Relax promise rules given how we use them in this project + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off", + }, + }, + + { + files: ["src/**/__tests__/**"], + + rules: { + // Ideally, enable these lint rules again later, as they are useful to + // catch bugs + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", + }, + }, +]; diff --git a/packages/liveblocks-lexical/package.json b/packages/liveblocks-lexical/package.json new file mode 100644 index 00000000000..ad433e6d5b4 --- /dev/null +++ b/packages/liveblocks-lexical/package.json @@ -0,0 +1,105 @@ +{ + "name": "@liveblocks/lexical", + "version": "3.24.0", + "description": "Lexical collaboration plugins backed by Liveblocks.", + "license": "Apache-2.0", + "author": "Liveblocks Inc.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "module": "./dist/index.js", + "default": "./dist/index.cjs" + } + }, + "./styles.css": { + "types": "./styles.css.d.cts", + "default": "./styles.css" + } + }, + "files": [ + "dist/**", + "**/*.css", + "**/*.css.d.cts", + "**/*.css.d.ts", + "**/*.css.map", + "README.md" + ], + "scripts": { + "dev": "rollup --config rollup.config.js --watch", + "build": "rollup --config rollup.config.js", + "format": "eslint --fix src/; stylelint --fix src/styles/; prettier --write src/", + "lint": "eslint src/; stylelint src/styles/", + "lint:package": "publint --strict && attw --pack", + "start": "pnpm run dev", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "test:watch": "vitest" + }, + "dependencies": { + "@liveblocks/client": "workspace:*", + "@liveblocks/core": "workspace:*" + }, + "peerDependencies": { + "@lexical/react": "^0.45", + "@lexical/selection": "^0.45", + "@lexical/utils": "^0.45", + "@liveblocks/react": "workspace:*", + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "lexical": "^0.45", + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + }, + "devDependencies": { + "@lexical/react": "0.45.0", + "@lexical/rich-text": "0.45.0", + "@lexical/selection": "0.45.0", + "@lexical/utils": "0.45.0", + "@liveblocks/eslint-config": "workspace:*", + "@liveblocks/react": "workspace:*", + "@liveblocks/rollup-config": "workspace:*", + "@liveblocks/vitest-config": "workspace:*", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.2.0", + "lexical": "0.45.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "rollup": "3.28.1", + "stylelint": "^15.10.2", + "stylelint-config-standard": "^34.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + }, + "sideEffects": false, + "bugs": { + "url": "https://github.com/liveblocks/liveblocks/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/liveblocks/liveblocks.git", + "directory": "packages/liveblocks-lexical" + }, + "homepage": "https://liveblocks.io", + "keywords": [ + "lexical", + "liveblocks", + "real-time", + "collaboration", + "collaborative", + "presence", + "crdts", + "synchronize", + "rooms", + "documents" + ] +} diff --git a/packages/liveblocks-lexical/rollup.config.js b/packages/liveblocks-lexical/rollup.config.js new file mode 100644 index 00000000000..f10ab31ea41 --- /dev/null +++ b/packages/liveblocks-lexical/rollup.config.js @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { createConfig } from "@liveblocks/rollup-config"; + +import pkg from "./package.json" with { type: "json" }; + +export default createConfig({ + pkg, + entries: ["src/index.ts"], + styles: [ + { + entry: "src/styles/index.css", + destination: "styles.css", + }, + ], +}); diff --git a/packages/liveblocks-lexical/src/__tests__/history.test.ts b/packages/liveblocks-lexical/src/__tests__/history.test.ts new file mode 100644 index 00000000000..e3e32a52e67 --- /dev/null +++ b/packages/liveblocks-lexical/src/__tests__/history.test.ts @@ -0,0 +1,3895 @@ +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { + LiveList, + LiveMap, + LiveObject, + LiveText, + type Room, +} from "@liveblocks/client"; +import { kInternal } from "@liveblocks/core"; +import { + $applyNodeReplacement, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $getSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + $setSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + CLEAR_EDITOR_COMMAND, + CLEAR_HISTORY_COMMAND, + COLLABORATION_TAG, + COMMAND_PRIORITY_CRITICAL, + createEditor as createLexicalEditor, + DecoratorNode, + type EditorConfig, + ElementNode, + HISTORIC_TAG, + HISTORY_MERGE_TAG, + HISTORY_PUSH_TAG, + type Klass, + type LexicalEditor, + type LexicalNode, + type LexicalUpdateJSON, + type NodeKey, + ParagraphNode, + PASTE_TAG, + REDO_COMMAND, + type SerializedElementNode, + type SerializedLexicalNode, + type Spread, + TextNode, + UNDO_COMMAND, +} from "lexical"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup"; +import { LiveblocksCollaboration } from "../collaboration"; +import { LiveblocksHistory } from "../history"; +import { + $getLexicalNodeProps, + LiveblocksCollaborationManager, +} from "../manager"; +import type { + LiveDecoratorNode, + LiveElementNode, + LiveRootNode, + LiveTextNode, +} from "../types"; + +describe("LiveblocksHistory", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe("commands", () => { + test("undo/redo restores Storage via room.history", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + // Capture uses setTimeout — fake only after room async setup finishes. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + + // History first, then Lexical → Storage (same order as the plugin). + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("Hello!"); + + // Flush the open capture via the idle timer. + vi.advanceTimersByTime(1000); + expect(room.history.canUndo()).toBe(true); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + expect(room.history.canRedo()).toBe(true); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello!"); + + unregisterSync(); + history.unregister(); + }); + + test("undo/redo projects Storage back into Lexical", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + + // Constructor schedules a non-discrete binding update; flush it first. + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + // Storage → Lexical, including local via:"undo"/via:"redo". + const unsubscribeStorage = room.subscribe( + document, + (updates) => { + if ( + updates.every((update) => { + const source = update.source; + return source.origin === "local" && source.via === "edit"; + }) + ) { + return; + } + + const isFromHistory = updates.some((update) => { + const source = update.source; + return ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ); + }); + + editor.update( + () => { + manager.$applyRemoteUpdates(updates); + }, + { + skipTransforms: true, + tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG, + } + ); + }, + { isDeep: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("Hello!"); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()) + ).toBe("Hello!"); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + // Nested historic editor.update commits on a microtask. + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()) + ).toBe("Hello"); + + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello!"); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()) + ).toBe("Hello!"); + + unsubscribeStorage(); + unregisterSync(); + history.unregister(); + }); + + test("UNDO_COMMAND returns false when the stack is empty", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + + expect(editor.dispatchCommand(UNDO_COMMAND, undefined)).toBe(false); + expect(content.toString()).toBe("Hello"); + + history.unregister(); + }); + + test("REDO_COMMAND returns false when the redo stack is empty", async () => { + const { room, document } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + + expect(editor.dispatchCommand(REDO_COMMAND, undefined)).toBe(false); + + history.unregister(); + }); + + test("commits the open capture group before undo", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("Hello!"); + // Still paused — nothing on the real undo stack yet. + expect(room[kInternal].undoStack).toHaveLength(0); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + expect(room.history.canUndo()).toBe(false); + + unregisterSync(); + history.unregister(); + }); + + test("commits the open capture group before redo", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(room[kInternal].undoStack).toHaveLength(0); + expect(room.history.canRedo()).toBe(false); + + // Redo with an empty redo stack must still flush the open capture + // (redo would otherwise discard pausedHistory). + expect(editor.dispatchCommand(REDO_COMMAND, undefined)).toBe(false); + expect(room[kInternal].undoStack).toHaveLength(1); + expect(content.toString()).toBe("Hello!"); + expect(room.history.canUndo()).toBe(true); + + unregisterSync(); + history.unregister(); + }); + + test("CLEAR_HISTORY_COMMAND empties the stacks without changing Storage", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + expect(room.history.canUndo()).toBe(true); + + expect(editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)).toBe( + true + ); + expect(room.history.canUndo()).toBe(false); + expect(room.history.canRedo()).toBe(false); + expect(content.toString()).toBe("Hello!"); + + unregisterSync(); + history.unregister(); + }); + + test("CLEAR_EDITOR_COMMAND clears history and returns false", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined)).toBe( + false + ); + expect(room.history.canUndo()).toBe(false); + expect(content.toString()).toBe("Hello!"); + + unregisterSync(); + history.unregister(); + }); + + test("dispatches CAN_UNDO / CAN_REDO as the stack changes", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + + let canUndo = false; + let canRedo = false; + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + canUndo = payload; + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + canRedo = payload; + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + expect(canUndo).toBe(false); + expect(canRedo).toBe(false); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + expect(canUndo).toBe(true); + expect(canRedo).toBe(false); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(canUndo).toBe(false); + expect(canRedo).toBe(true); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(canUndo).toBe(true); + expect(canRedo).toBe(false); + + unregisterSync(); + history.unregister(); + }); + + test("a new edit after undo clears the redo stack", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + + // Need Storage → Lexical so undo leaves the editor matching Storage + // before the branching edit. + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + const unsubscribeStorage = room.subscribe( + document, + (updates) => { + if ( + updates.every((update) => { + const source = update.source; + return source.origin === "local" && source.via === "edit"; + }) + ) { + return; + } + + const isFromHistory = updates.some((update) => { + const source = update.source; + return ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ); + }); + + editor.update( + () => { + manager.$applyRemoteUpdates(updates); + }, + { + skipTransforms: true, + tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG, + } + ); + }, + { isDeep: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect(room.history.canRedo()).toBe(true); + + // Branching edit — redo of "!" must be discarded. + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + // Mutation-while-paused must clear redo immediately (before idle commit). + expect(content.toString()).toBe("Hello?"); + expect(room.history.canRedo()).toBe(false); + + vi.advanceTimersByTime(1000); + expect(room.history.canUndo()).toBe(true); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(content.toString()).toBe("Hello"); + expect(room.history.canRedo()).toBe(true); + + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(content.toString()).toBe("Hello?"); + expect(room.history.canRedo()).toBe(false); + + unsubscribeStorage(); + unregisterSync(); + history.unregister(); + }); + + test("historic projection does not echo back into Storage", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + const unsubscribeStorage = room.subscribe( + document, + (updates) => { + if ( + updates.every((update) => { + const source = update.source; + return source.origin === "local" && source.via === "edit"; + }) + ) { + return; + } + + const isFromHistory = updates.some((update) => { + const source = update.source; + return ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ); + }); + + editor.update( + () => { + manager.$applyRemoteUpdates(updates); + }, + { + skipTransforms: true, + tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG, + } + ); + }, + { isDeep: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()) + ).toBe("Hello"); + + // If HISTORIC_TAG failed to skip Lexical → Storage, a new capture would + // open and the idle timer would push another undo item. + vi.advanceTimersByTime(1000); + expect(content.toString()).toBe("Hello"); + expect(room.history.canUndo()).toBe(false); + expect(room[kInternal].undoStack).toHaveLength(0); + + unsubscribeStorage(); + unregisterSync(); + history.unregister(); + }); + + test("unregister commits any open capture group", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(room[kInternal].undoStack).toHaveLength(0); + + unregisterSync(); + history.unregister(); + expect(room[kInternal].undoStack).toHaveLength(1); + expect(content.toString()).toBe("Hello!"); + }); + }); + + describe("grouping", () => { + test("merges consecutive dirty edits while the capture is open", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!?"); + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + expect(room.history.canUndo()).toBe(false); + + unregisterSync(); + history.unregister(); + }); + + test("starts a new undo item after the idle timer commits", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!?"); + expect(room[kInternal].undoStack.length).toBeGreaterThanOrEqual(2); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello!"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("resets the idle timer when the capture is extended", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(800); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + // Resets the idle window. + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(800); + // Still capturing — first timer was cleared; second has 200ms left. + expect(room[kInternal].undoStack).toHaveLength(0); + + vi.advanceTimersByTime(200); + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("does not close the capture on selection-only updates", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + + // Caret moves: dirtyLeaves/Elements empty — must not commit capture. + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(0, 0); + }, + { discrete: true } + ); + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(3, 3); + }, + { discrete: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!?"); + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("ignores collaboration-tagged updates for capture", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true, tag: COLLABORATION_TAG } + ); + vi.advanceTimersByTime(1000); + + // Sync also skips COLLABORATION_TAG — Storage unchanged, no capture. + expect(content.toString()).toBe("Hello"); + expect(room.history.canUndo()).toBe(false); + + unregisterSync(); + history.unregister(); + }); + + test("ignores historic-tagged updates for capture", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true, tag: HISTORIC_TAG } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello"); + expect(room.history.canUndo()).toBe(false); + + unregisterSync(); + history.unregister(); + }); + + test("historic and collaboration updates do not disturb an open capture", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + expect(room[kInternal].undoStack).toHaveLength(0); + + // Mid-capture peer/undo projections must not commit the pause group. + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("X"); + }, + { discrete: true, tag: COLLABORATION_TAG } + ); + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("Y"); + }, + { discrete: true, tag: HISTORIC_TAG } + ); + expect(room[kInternal].undoStack).toHaveLength(0); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true } + ); + expect(room[kInternal].undoStack).toHaveLength(0); + vi.advanceTimersByTime(1000); + + // One stack item for the whole capture (tagged updates never committed). + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("merges structural dirty edits within the idle window", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + // Sync full document text so paragraph splits are reflected in Storage. + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + + // Paragraph insert dirties elements — still one capture with the insert. + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("World")) + ); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + // Lexical joins blocks with "\n\n" in getTextContent(). + expect(content.toString()).toBe("Hello!\n\nWorld"); + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + }); + + describe("boundaries", () => { + test("treats PASTE_TAG as a hard undo boundary", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.clear(); + paragraph.append($createTextNode("Hello!PASTE")); + }, + { discrete: true, tag: PASTE_TAG } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!PASTE"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello!"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("treats HISTORY_PUSH_TAG as a hard boundary within the idle window", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true, tag: HISTORY_PUSH_TAG } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!?"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello!"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("HISTORY_MERGE_TAG prevents PASTE_TAG from splitting the capture", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + // MERGE is checked before hard boundaries — paste must not commit. + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.clear(); + paragraph.append($createTextNode("Hello!PASTE")); + }, + { discrete: true, tag: [HISTORY_MERGE_TAG, PASTE_TAG] } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("Hello!PASTE"); + expect(room[kInternal].undoStack).toHaveLength(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("HISTORY_MERGE_TAG extends an open capture like a normal dirty edit", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("!"); + }, + { discrete: true } + ); + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.selectEnd().insertText("?"); + }, + { discrete: true, tag: HISTORY_MERGE_TAG } + ); + vi.advanceTimersByTime(1000); + + expect(room[kInternal].undoStack).toHaveLength(1); + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + + test("two PASTE_TAG updates within the idle window stay separate", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const manager = new LiveblocksCollaborationManager(document, editor); + editor.update(() => {}, { discrete: true }); + const history = new LiveblocksHistory(editor, room, manager); + history.register(); + const unregisterSync = editor.registerUpdateListener( + ({ tags, editorState }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + editorState.read(() => { + const plain = $getRoot().getTextContent(); + room.batch(() => { + const current = content.toString(); + if (current === plain) return; + content.delete(0, current.length); + if (plain.length > 0) content.insert(0, plain); + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.clear(); + paragraph.append($createTextNode("HelloA")); + }, + { discrete: true, tag: PASTE_TAG } + ); + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.clear(); + paragraph.append($createTextNode("HelloAB")); + }, + { discrete: true, tag: PASTE_TAG } + ); + vi.advanceTimersByTime(1000); + + expect(content.toString()).toBe("HelloAB"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("HelloA"); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(content.toString()).toBe("Hello"); + + unregisterSync(); + history.unregister(); + }); + }); + + describe("selection restore", () => { + test("undo restores the caret from before a local insert", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + // Capture uses setTimeout — fake only after room async setup finishes. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + // Constructor schedules a non-discrete binding update; flush it first. + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + // Prefer $setSelection over TextNode.select() for selection-only updates — + // the latter can dirty the text node and open a capture with the wrong before. + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 5, "text"); + selection.focus.set(textNode.getKey(), 5, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(5, 5).insertText("!"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("Hello!"); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + // Nested historic editor.update commits on a microtask. + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return { + offset: selection.anchor.offset, + collapsed: selection.isCollapsed(), + type: selection.anchor.type, + }; + }) + ).toEqual({ offset: 5, collapsed: true, type: "text" }); + + collaboration.unregister(); + }); + + test("redo restores the caret from after a local insert", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 5, "text"); + selection.focus.set(textNode.getKey(), 5, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(5, 5).insertText("!"); + }, + { discrete: true } + ); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello!"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return { + offset: selection.anchor.offset, + collapsed: selection.isCollapsed(), + type: selection.anchor.type, + }; + }) + ).toEqual({ offset: 6, collapsed: true, type: "text" }); + + collaboration.unregister(); + }); + + test("undo restores a mid-text caret after insert", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 2, "text"); + selection.focus.set(textNode.getKey(), 2, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(2, 2).insertText("X"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("HeXllo"); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return { + offset: selection.anchor.offset, + collapsed: selection.isCollapsed(), + type: selection.anchor.type, + }; + }) + ).toEqual({ offset: 2, collapsed: true, type: "text" }); + + collaboration.unregister(); + }); + + test("continuing to type after undo does not use a stale before selection", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + // Cycle 1: He|llo → HeXllo → undo → He|llo + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 2, "text"); + selection.focus.set(textNode.getKey(), 2, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(2, 2).insertText("X"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("HeXllo"); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return selection.anchor.offset; + }) + ).toBe(2); + + // Cycle 2: insert again from the restored caret — no fresh selection-only + // update. After undo, collaboration sets `history.pendingBefore` from a + // freshly encoded selection. Without that, `#pendingBefore` would still + // be the previous item's `after` (offset 3) and this undo would land on + // Hell|o. + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + textNode.select(2, 2).insertText("X"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("HeXllo"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return selection.anchor.offset; + }) + ).toBe(3); + + vi.advanceTimersByTime(1000); + + let restoreOnSecondUndo: { offset: number } | null = null; + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const restore = collaboration.history.pendingRestore; + if (restore === null) return; + restoreOnSecondUndo = { offset: restore.storage.anchor.offset }; + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello"); + expect(restoreOnSecondUndo).toEqual({ offset: 2 }); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return selection.anchor.offset; + }) + ).toBe(2); + + collaboration.unregister(); + }); + + test("DIAG dirty flags on first selection after collab init", async () => { + const { room, document } = await createRoomWithText("First"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("First"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + const observations: Array<{ + dirtyLeaves: number; + dirtyElements: number; + tags: string[]; + binding: number; + encoded: unknown; + }> = []; + + const unsub = editor.registerUpdateListener( + ({ editorState, dirtyLeaves, dirtyElements, tags }) => { + observations.push( + editorState.read(() => ({ + dirtyLeaves: dirtyLeaves.size, + dirtyElements: dirtyElements.size, + tags: [...tags], + binding: collaboration.manager.binding.reverse.size, + encoded: collaboration.manager.$encodeSelection(), + })) + ); + } + ); + + // First selection after init + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 1, "text"); + selection.focus.set(text.getKey(), 4, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + unsub(); + // Soft assert — print via expect for visibility + expect(observations).toEqual([ + expect.objectContaining({ + dirtyLeaves: 0, + dirtyElements: 0, + }), + ]); + + collaboration.unregister(); + }); + + test("DIAG select then delete without discrete (raf-batched)", async () => { + const { room, document } = await createRoomWithText("First"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("First"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + // Non-discrete: Lexical may merge updates in the same flush window. + editor.update(() => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 1, "text"); + selection.focus.set(text.getKey(), 4, "text"); + $setSelection(selection); + }); + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }); + + // Flush pending Lexical updates + editor.update(() => {}, { discrete: true }); + + const content = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + expect(content.toString()).toBe("Ft"); + + vi.advanceTimersByTime(1000); + + let restore: { a: number; f: number } | null = null; + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const r = collaboration.history.pendingRestore; + if (r === null) return; + restore = { a: r.lexical.anchor.offset, f: r.lexical.focus.offset }; + }); + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect({ + restore, + selection: editor.read(() => { + const s = $getSelection(); + if (!$isRangeSelection(s)) return null; + return { + a: s.anchor.offset, + f: s.focus.offset, + c: s.isCollapsed(), + }; + }), + }).toEqual({ + restore: { a: 1, f: 4 }, + selection: { a: 1, f: 4, c: false }, + }); + + collaboration.unregister(); + }); + + test("DIAG first selection before binding flush then delete", async () => { + const { room, document } = await createRoomWithText("First"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("First"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + // No binding flush — simulate racing first interaction. + collaboration.register(); + + const encBeforeFlush = editor.read(() => ({ + binding: collaboration.manager.binding.reverse.size, + encoded: collaboration.manager.$encodeSelection(), + })); + + // Selection against pre-rebuild keys (createEditor tree), while manager + // rebuild may still be pending. + editor.update(() => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 1, "text"); + selection.focus.set(text.getKey(), 4, "text"); + $setSelection(selection); + }); + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }); + + editor.update(() => {}, { discrete: true }); + + const content = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + + vi.advanceTimersByTime(1000); + + let restore: unknown = "unset"; + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + restore = collaboration.history.pendingRestore; + }); + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect({ + encBeforeFlush, + content: content.toString(), + restore, + selection: editor.read(() => { + const s = $getSelection(); + if (!$isRangeSelection(s)) return null; + return { + a: s.anchor.offset, + f: s.focus.offset, + c: s.isCollapsed(), + }; + }), + }).toEqual({ + encBeforeFlush: expect.anything(), + content: "First", + restore: expect.objectContaining({ + lexical: expect.objectContaining({ + anchor: expect.objectContaining({ offset: 1 }), + }), + }), + selection: { a: 1, f: 4, c: false }, + }); + + collaboration.unregister(); + }); + + test("undo restores a partial text range selection after delete", async () => { + const { room, document } = await createRoomWithText("First"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("First"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 1, "text"); + selection.focus.set(text.getKey(), 4, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + const content = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + expect(content.toString()).toBe("Ft"); + + vi.advanceTimersByTime(1000); + + let restoreAtUndo: { + storageAnchor: number; + storageFocus: number; + lexicalAnchor: number; + lexicalFocus: number; + decodeAnchor: number | null; + } | null = null; + + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const restore = collaboration.history.pendingRestore; + if (restore === null) return; + restoreAtUndo = { + storageAnchor: restore.storage.anchor.offset, + storageFocus: restore.storage.focus.offset, + lexicalAnchor: restore.lexical.anchor.offset, + lexicalFocus: restore.lexical.focus.offset, + decodeAnchor: content[kInternal].decodeIndex( + restore.storage.anchor.offset, + restore.storage.anchor.version + ), + }; + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("First"); + // Storage decode still remaps the left edge (1 → 4), but Lexical + // snapshot keeps the pre-delete offsets and is preferred when the key + // is still bound. + expect(restoreAtUndo).toEqual({ + storageAnchor: 1, + storageFocus: 4, + lexicalAnchor: 1, + lexicalFocus: 4, + decodeAnchor: 4, + }); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return { + anchor: { + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focus: { + offset: selection.focus.offset, + type: selection.focus.type, + }, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + anchor: { offset: 1, type: "text" }, + focus: { offset: 4, type: "text" }, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("undo restores a multi-paragraph range selection after delete", async () => { + const { room, document } = await createTwoParagraphRoom(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration } = createCollaborationFromDocument( + room, + document + ); + + editor.update( + () => { + const paragraphs = $getRoot() + .getChildren() + .filter($isParagraphNode) as ParagraphNode[]; + const first = paragraphs[0]!; + const secondText = paragraphs[1]!.getFirstChild(); + if (secondText === null || !$isTextNode(secondText)) { + throw new Error("Expected text in second paragraph"); + } + + const selection = $createRangeSelection(); + selection.anchor.set(first.getKey(), 0, "element"); + selection.focus.set( + secondText.getKey(), + secondText.getTextContentSize(), + "text" + ); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + expect(document.get("children").length).toBe(1); + + vi.advanceTimersByTime(1000); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.get("children").length).toBe(2); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const paragraphs = $getRoot() + .getChildren() + .filter($isParagraphNode) as ParagraphNode[]; + const first = paragraphs[0]!; + const secondText = paragraphs[1]!.getFirstChild(); + if (secondText === null || !$isTextNode(secondText)) { + return null; + } + return { + anchorMatchesFirst: selection.anchor.key === first.getKey(), + anchor: { + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focusMatchesSecondText: selection.focus.key === secondText.getKey(), + focus: { + offset: selection.focus.offset, + type: selection.focus.type, + }, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + anchorMatchesFirst: true, + anchor: { offset: 0, type: "element" }, + focusMatchesSecondText: true, + focus: { offset: 6, type: "text" }, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("undo restores text-to-text multi-paragraph selection offsets after delete", async () => { + const { room, document } = await createTwoParagraphRoom(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration, manager } = + createCollaborationFromDocument(room, document); + + editor.update( + () => { + const paragraphs = $getRoot() + .getChildren() + .filter($isParagraphNode) as ParagraphNode[]; + const firstText = paragraphs[0]!.getFirstChild(); + const secondText = paragraphs[1]!.getFirstChild(); + if ( + firstText === null || + !$isTextNode(firstText) || + secondText === null || + !$isTextNode(secondText) + ) { + throw new Error("Expected text in both paragraphs"); + } + + const selection = $createRangeSelection(); + selection.anchor.set(firstText.getKey(), 1, "text"); + selection.focus.set( + secondText.getKey(), + secondText.getTextContentSize(), + "text" + ); + $setSelection(selection); + }, + { discrete: true } + ); + + const beforeSelection = editor.read(() => manager.$encodeSelection()); + expect(beforeSelection).not.toBeNull(); + expect(beforeSelection!.anchor.offset).toBe(1); + expect(beforeSelection!.focus.offset).toBe(6); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + vi.advanceTimersByTime(1000); + + let decodeAtUndo: { + storageAnchor: number; + lexicalAnchor: number; + decodeAnchor: number | null; + } | null = null; + const firstContent = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const restore = collaboration.history.pendingRestore; + if (restore === null) return; + decodeAtUndo = { + storageAnchor: restore.storage.anchor.offset, + lexicalAnchor: restore.lexical.anchor.offset, + decodeAnchor: firstContent[kInternal].decodeIndex( + restore.storage.anchor.offset, + restore.storage.anchor.version + ), + }; + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + // Storage decode remaps surviving first-paragraph endpoint (1 → 5); + // Lexical snapshot keeps offset 1 and is preferred when the key survives. + // Focus on the recreated second paragraph falls back to storage decode. + expect(decodeAtUndo).toEqual({ + storageAnchor: 1, + lexicalAnchor: 1, + decodeAnchor: 5, + }); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const paragraphs = $getRoot() + .getChildren() + .filter($isParagraphNode) as ParagraphNode[]; + const firstText = paragraphs[0]!.getFirstChild(); + const secondText = paragraphs[1]!.getFirstChild(); + if ( + firstText === null || + !$isTextNode(firstText) || + secondText === null || + !$isTextNode(secondText) + ) { + return null; + } + return { + anchorMatchesFirstText: selection.anchor.key === firstText.getKey(), + anchor: { + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focusMatchesSecondText: selection.focus.key === secondText.getKey(), + focus: { + offset: selection.focus.offset, + type: selection.focus.type, + }, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + anchorMatchesFirstText: true, + anchor: { offset: 1, type: "text" }, + focusMatchesSecondText: true, + focus: { offset: 6, type: "text" }, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("redo restores the post-delete collapsed caret after multi-paragraph delete", async () => { + const { room, document } = await createTwoParagraphRoom(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration, manager } = + createCollaborationFromDocument(room, document); + + editor.update( + () => { + const paragraphs = $getRoot() + .getChildren() + .filter($isParagraphNode) as ParagraphNode[]; + const first = paragraphs[0]!; + const secondText = paragraphs[1]!.getFirstChild(); + if (secondText === null || !$isTextNode(secondText)) { + throw new Error("Expected text in second paragraph"); + } + + const selection = $createRangeSelection(); + selection.anchor.set(first.getKey(), 0, "element"); + selection.focus.set( + secondText.getKey(), + secondText.getTextContentSize(), + "text" + ); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + vi.advanceTimersByTime(1000); + + const afterDeleteSelection = editor.read(() => + manager.$encodeSelection() + ); + expect(afterDeleteSelection).not.toBeNull(); + expect(afterDeleteSelection!.anchor).toEqual(afterDeleteSelection!.focus); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.get("children").length).toBe(1); + const restored = editor.read(() => manager.$encodeSelection()); + expect(restored).not.toBeNull(); + expect(restored!.anchor).toEqual(afterDeleteSelection!.anchor); + expect(restored!.focus).toEqual(afterDeleteSelection!.focus); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return selection.isCollapsed(); + }) + ).toBe(true); + + collaboration.unregister(); + }); + + test("clearing history does not leave a pending selection restore", async () => { + const { room, document, content } = await createRoomWithText("Hello"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hello"); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const textNode = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (textNode === null || !$isTextNode(textNode)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 2, "text"); + selection.focus.set(textNode.getKey(), 4, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.insertText("X"); + }, + { discrete: true } + ); + expect(content.toString()).toBe("HeXo"); + + vi.advanceTimersByTime(1000); + room.history.clear(); + + expect(collaboration.history.pendingRestore).toBeNull(); + expect(room.history.canUndo()).toBe(false); + + collaboration.unregister(); + }); + + test("undo restores a range selection after deleting across mixed formatting", async () => { + // "Hello " (plain) + "world" (bold). Select from offset 3 in plain + // through the bold span, delete, undo — selection should cover + // "lo world" again (flat 3–11), even if TextNode keys are recreated. + const { room, document, content } = await createRoomWithFormattedText([ + ["Hello "], + ["world", { bold: true }], + ]); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration, manager } = + createCollaborationFromDocument(room, document); + + expect(content.toString()).toBe("Hello world"); + expect( + editor.read(() => { + const texts = ( + $getRoot().getFirstChild() as ParagraphNode + ).getChildren(); + return texts.map((node) => { + if (!$isTextNode(node)) return null; + return { + text: node.getTextContent(), + bold: node.hasFormat("bold"), + }; + }); + }) + ).toEqual([ + { text: "Hello ", bold: false }, + { text: "world", bold: true }, + ]); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const plain = paragraph.getFirstChild(); + const bold = paragraph.getLastChild(); + if ( + plain === null || + !$isTextNode(plain) || + bold === null || + !$isTextNode(bold) + ) { + throw new Error("Expected plain + bold text nodes"); + } + const selection = $createRangeSelection(); + selection.anchor.set(plain.getKey(), 3, "text"); + selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + const beforeSelection = editor.read(() => manager.$encodeSelection()); + expect(beforeSelection).not.toBeNull(); + expect(beforeSelection!.anchor.offset).toBe(3); + expect(beforeSelection!.focus.offset).toBe(11); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + expect(content.toString()).toBe("Hel"); + vi.advanceTimersByTime(1000); + + let restoreAtUndo: { + storageAnchor: number; + storageFocus: number; + localAnchor: number; + localFocus: number; + lexicalAnchorOffset: number; + lexicalFocusOffset: number; + decodeAnchor: number | null; + } | null = null; + + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const restore = collaboration.history.pendingRestore; + if (restore === null) return; + restoreAtUndo = { + storageAnchor: restore.storage.anchor.offset, + storageFocus: restore.storage.focus.offset, + localAnchor: restore.local.anchor.offset, + localFocus: restore.local.focus.offset, + lexicalAnchorOffset: restore.lexical.anchor.offset, + lexicalFocusOffset: restore.lexical.focus.offset, + decodeAnchor: content[kInternal].decodeIndex( + restore.storage.anchor.offset, + restore.storage.anchor.version + ), + }; + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello world"); + // Storage decode remaps the left edge (3 → 11). Local flat offsets keep + // the pre-delete range and are used when Lexical keys were recreated. + expect(restoreAtUndo).toEqual({ + storageAnchor: 3, + storageFocus: 11, + localAnchor: 3, + localFocus: 11, + lexicalAnchorOffset: 3, + lexicalFocusOffset: 5, + decodeAnchor: 11, + }); + + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const plain = paragraph.getFirstChild(); + const bold = paragraph.getLastChild(); + if ( + plain === null || + !$isTextNode(plain) || + bold === null || + !$isTextNode(bold) + ) { + return null; + } + return { + text: $getRoot().getTextContent(), + segments: paragraph.getChildren().map((node) => { + if (!$isTextNode(node)) return null; + return { + text: node.getTextContent(), + bold: node.hasFormat("bold"), + }; + }), + anchorMatchesPlain: selection.anchor.key === plain.getKey(), + anchor: { + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focusMatchesBold: selection.focus.key === bold.getKey(), + focus: { + offset: selection.focus.offset, + type: selection.focus.type, + }, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + text: "Hello world", + segments: [ + { text: "Hello ", bold: false }, + { text: "world", bold: true }, + ], + anchorMatchesPlain: true, + anchor: { offset: 3, type: "text" }, + focusMatchesBold: true, + focus: { offset: 5, type: "text" }, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("undo selection restore ignores stale reverse bindings for detached keys", async () => { + // Repro: binding.reverse.has(key) can stay true for a key that + // $getNodeByKey returns null for. Preferring that lexical snapshot + // used to throw PointType.set: node with key X is [not found]. + const { room, document, content } = await createRoomWithFormattedText([ + ["Hello "], + ["world", { bold: true }], + ]); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration, manager } = + createCollaborationFromDocument(room, document); + + const liveText = (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode; + + let staleAnchorKey: string | null = null; + let staleFocusKey: string | null = null; + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const plain = paragraph.getFirstChild(); + const bold = paragraph.getLastChild(); + if ( + plain === null || + !$isTextNode(plain) || + bold === null || + !$isTextNode(bold) + ) { + throw new Error("Expected plain + bold text nodes"); + } + staleAnchorKey = plain.getKey(); + staleFocusKey = bold.getKey(); + const selection = $createRangeSelection(); + selection.anchor.set(plain.getKey(), 3, "text"); + selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + expect(content.toString()).toBe("Hel"); + expect(staleAnchorKey).not.toBeNull(); + expect(staleFocusKey).not.toBeNull(); + + // Detached keys that createBinding will not scrub (not in forward[]). + (manager.binding.reverse as Map).set( + staleAnchorKey!, + liveText + ); + (manager.binding.reverse as Map).set( + staleFocusKey!, + liveText + ); + + vi.advanceTimersByTime(1000); + + expect(() => { + editor.dispatchCommand(UNDO_COMMAND, undefined); + }).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello world"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + return { + text: $getRoot().getTextContent(), + anchor: { + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focus: { + offset: selection.focus.offset, + type: selection.focus.type, + }, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + text: "Hello world", + anchor: { offset: 3, type: "text" }, + focus: { offset: 5, type: "text" }, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("undo restores a range selection after deleting inside a bold span", async () => { + // Uniform bold LiveText — same left-edge decode remap as plain text, + // but the TextNode carries formatting. Select "orl" in "world", delete, + // undo → selection should land back on offsets 1–4 of the bold node. + const { room, document, content } = await createRoomWithFormattedText([ + ["world", { bold: true }], + ]); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration } = createCollaborationFromDocument( + room, + document + ); + + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected bold text node"); + } + expect(text.hasFormat("bold")).toBe(true); + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 1, "text"); + selection.focus.set(text.getKey(), 4, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + expect(content.toString()).toBe("wd"); + vi.advanceTimersByTime(1000); + + let decodeAtUndo: number | null = null; + const unsub = room[kInternal].history.subscribe((event) => { + if (event.action !== "undo") return; + const restore = collaboration.history.pendingRestore; + if (restore === null) return; + decodeAtUndo = content[kInternal].decodeIndex( + restore.storage.anchor.offset, + restore.storage.anchor.version + ); + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + unsub(); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("world"); + // Storage decode still remaps the left edge (1 → 4); Lexical snapshot + // must win when the bold TextNode key survives (single-segment path). + expect(decodeAtUndo).toBe(4); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + return null; + } + return { + bold: text.hasFormat("bold"), + anchorMatches: selection.anchor.key === text.getKey(), + focusMatches: selection.focus.key === text.getKey(), + anchor: selection.anchor.offset, + focus: selection.focus.offset, + isCollapsed: selection.isCollapsed(), + }; + }) + ).toEqual({ + bold: true, + anchorMatches: true, + focusMatches: true, + anchor: 1, + focus: 4, + isCollapsed: false, + }); + + collaboration.unregister(); + }); + + test("undo restores selection after deleting only the bold sibling", async () => { + // Delete the entire bold sibling while leaving plain text. Undo must + // re-select "world". Flat offset 6 is the plain|bold boundary — decode + // may land at end of plain or start of bold; both select the same text. + const { room, document, content } = await createRoomWithFormattedText([ + ["Hello "], + ["world", { bold: true }], + ]); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { editor, collaboration, manager } = + createCollaborationFromDocument(room, document); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const bold = paragraph.getLastChild(); + if (bold === null || !$isTextNode(bold)) { + throw new Error("Expected bold text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(bold.getKey(), 0, "text"); + selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error("Expected range selection"); + } + selection.removeText(); + }, + { discrete: true } + ); + + expect(content.toString()).toBe("Hello "); + vi.advanceTimersByTime(1000); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(content.toString()).toBe("Hello world"); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + return { + segments: paragraph.getChildren().map((node) => { + if (!$isTextNode(node)) return null; + return { + text: node.getTextContent(), + bold: node.hasFormat("bold"), + }; + }), + selectedText: selection.getTextContent(), + isCollapsed: selection.isCollapsed(), + local: { + anchor: manager.$encodeLocalPoint(selection.anchor)?.offset, + focus: manager.$encodeLocalPoint(selection.focus)?.offset, + }, + }; + }) + ).toEqual({ + segments: [ + { text: "Hello ", bold: false }, + { text: "world", bold: true }, + ], + selectedText: "world", + isCollapsed: false, + local: { anchor: 6, focus: 11 }, + }); + + collaboration.unregister(); + }); + }); + + describe("decorator nodes", () => { + test("undo/redo insert and remove of a decorator child", async () => { + const { room, document } = await createRoomWithText("Hi"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hi", [CustomDecoratorNode]); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(paragraph_liveblocks.get("children").length).toBe(2); + expect(paragraph_liveblocks.get("children").get(1)!.get("kind")).toBe( + "decorator" + ); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(paragraph_liveblocks.get("children").length).toBe(1); + expect( + editor.read(() => + ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize() + ) + ).toBe(1); + + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(paragraph_liveblocks.get("children").length).toBe(2); + expect(paragraph_liveblocks.get("children").get(1)!.get("kind")).toBe( + "decorator" + ); + expect( + (paragraph_liveblocks.get("children").get(1)! as LiveDecoratorNode) + .get("props") + ?.toJSON() + ).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + + collaboration.unregister(); + }); + + test("undo restores the caret from before a decorator insert", async () => { + const { room, document } = await createRoomWithText("Hi"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("Hi", [CustomDecoratorNode]); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 2, "text"); + selection.focus.set(text.getKey(), 2, "text"); + $setSelection(selection); + }, + { discrete: true } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect( + (document.get("children").get(0) as LiveElementNode).get("children") + .length + ).toBe(2); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect( + (document.get("children").get(0) as LiveElementNode).get("children") + .length + ).toBe(1); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + return { + offset: selection.anchor.offset, + collapsed: selection.isCollapsed(), + type: selection.anchor.type, + onText: text !== null && selection.anchor.key === text.getKey(), + }; + }) + ).toEqual({ + offset: 2, + collapsed: true, + type: "text", + onText: true, + }); + + collaboration.unregister(); + }); + + test("undo/redo decorator prop changes", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.history.disable(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("", [CustomDecoratorNode]); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + const decorator_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveDecoratorNode; + + editor.update( + () => { + const decorator = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild() as CustomDecoratorNode; + const writable = decorator.getWritable(); + writable.__src = "https://example.com/b.png"; + writable.__altText = "B"; + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({ + src: "https://example.com/b.png", + altText: "B", + }); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + expect( + editor.read(() => { + const decorator = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild() as CustomDecoratorNode; + return $getLexicalNodeProps(decorator); + }) + ).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + + collaboration.unregister(); + }); + }); + + describe("inline element undo with formatted text", () => { + test("undo mark next to bold does not duplicate trailing text in Lexical", async () => { + const { room, document } = await createRoomWithText("How are you?"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("How are you?", [InlineMarkNode]); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + // Bold "are" → multi-segment LiveText under one storage child. + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 4, "text"); + selection.focus.set(text.getKey(), 7, "text"); + $setSelection(selection); + selection.formatText("bold"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + // Mark plain suffix " you" next to the bold span (leaves trailing "?"). + editor.update( + () => { + const paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) { + throw new Error("Expected paragraph"); + } + const suffix = paragraph + .getChildren() + .find( + (child) => + $isTextNode(child) && child.getTextContent() === " you?" + ); + if (suffix === undefined || !$isTextNode(suffix)) { + throw new Error("Expected suffix text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(suffix.getKey(), 0, "text"); + selection.focus.set(suffix.getKey(), 4, "text"); + $setSelection(selection); + $wrapSelectionInInlineMark(); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + expect(paragraph_liveblocks.get("children").length).toBeGreaterThan(1); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect( + paragraph_liveblocks.get("children").map((child) => ({ + kind: child.get("kind"), + content: + child.get("kind") === "text" + ? (child as LiveTextNode).get("content").toJSON() + : undefined, + })) + ).toEqual([ + { + kind: "text", + content: [["How "], ["are", { bold: true }], [" you?"]], + }, + ]); + + expect( + editor.read(() => { + const paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) { + throw new Error("Expected paragraph"); + } + return { + text: paragraph.getTextContent(), + hasMark: paragraph + .getChildren() + .some((child) => $isInlineMarkNode(child)), + }; + }) + ).toEqual({ + text: "How are you?", + hasMark: false, + }); + + collaboration.unregister(); + }); + + test("undo mark wrapping the bold span restores formatted text", async () => { + const { room, document } = await createRoomWithText("How are you?"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const editor = createEditor("How are you?", [InlineMarkNode]); + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild(); + if (text === null || !$isTextNode(text)) { + throw new Error("Expected text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 4, "text"); + selection.focus.set(text.getKey(), 7, "text"); + $setSelection(selection); + selection.formatText("bold"); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) { + throw new Error("Expected paragraph"); + } + const bold = paragraph + .getChildren() + .find((child) => $isTextNode(child) && child.hasFormat("bold")); + if (bold === undefined || !$isTextNode(bold)) { + throw new Error("Expected bold text node"); + } + const selection = $createRangeSelection(); + selection.anchor.set(bold.getKey(), 0, "text"); + selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text"); + $setSelection(selection); + $wrapSelectionInInlineMark(); + }, + { discrete: true } + ); + vi.advanceTimersByTime(1000); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect( + editor.read(() => { + const paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) { + throw new Error("Expected paragraph"); + } + return { + text: paragraph.getTextContent(), + hasMark: paragraph + .getChildren() + .some((child) => $isInlineMarkNode(child)), + spans: paragraph.getChildren().map((child) => ({ + type: child.getType(), + text: child.getTextContent(), + bold: $isTextNode(child) ? child.hasFormat("bold") : false, + })), + }; + }) + ).toEqual({ + text: "How are you?", + hasMark: false, + spans: [ + { type: "text", text: "How ", bold: false }, + { type: "text", text: "are", bold: true }, + { type: "text", text: " you?", bold: false }, + ], + }); + + collaboration.unregister(); + }); + }); +}); + +type SerializedInlineMarkNode = Spread< + { type: "inline-mark"; ids: string[] }, + SerializedElementNode +>; + +/** Minimal MarkNode stand-in — inline element that splits paragraph children. */ +class InlineMarkNode extends ElementNode { + __ids: string[]; + + static getType(): string { + return "inline-mark"; + } + + static clone(node: InlineMarkNode): InlineMarkNode { + return new InlineMarkNode(node.__ids, node.__key); + } + + constructor(ids: string[] = ["mark"], key?: NodeKey) { + super(key); + this.__ids = ids; + } + + createDOM(_config: EditorConfig): HTMLElement { + return document.createElement("mark"); + } + + updateDOM(): boolean { + return false; + } + + isInline(): true { + return true; + } + + exportJSON(): SerializedInlineMarkNode { + return { + ...super.exportJSON(), + type: "inline-mark", + ids: this.__ids, + }; + } + + static importJSON(serialized: SerializedInlineMarkNode): InlineMarkNode { + return $createInlineMarkNode(serialized.ids); + } +} + +function $createInlineMarkNode(ids: string[] = ["mark"]): InlineMarkNode { + return $applyNodeReplacement(new InlineMarkNode(ids)); +} + +function $isInlineMarkNode( + node: LexicalNode | null | undefined +): node is InlineMarkNode { + return node instanceof InlineMarkNode; +} + +function $wrapSelectionInInlineMark(): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + throw new Error("Expected non-collapsed range selection"); + } + const nodes = selection.extract(); + if (nodes.length === 0) { + throw new Error("Expected extracted nodes"); + } + const mark = $createInlineMarkNode(); + nodes[0]!.insertBefore(mark); + for (const node of nodes) { + mark.append(node); + } +} + +type SerializedCustomDecoratorNode = Spread< + { + src: string; + altText: string; + }, + SerializedLexicalNode +>; + +class CustomDecoratorNode extends DecoratorNode { + __src: string; + __altText: string; + + static getType(): string { + return "custom-decorator"; + } + + static clone(node: CustomDecoratorNode): CustomDecoratorNode { + return new CustomDecoratorNode(node.__src, node.__altText, node.__key); + } + + static importJSON( + serializedNode: SerializedCustomDecoratorNode + ): CustomDecoratorNode { + return $createCustomDecoratorNode().updateFromJSON(serializedNode); + } + + constructor(src = "", altText = "", key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText; + } + + exportJSON(): SerializedCustomDecoratorNode { + return { + ...super.exportJSON(), + src: this.__src, + altText: this.__altText, + }; + } + + updateFromJSON( + serializedNode: LexicalUpdateJSON + ): this { + const node = super.updateFromJSON(serializedNode); + const writable = node.getWritable(); + if (serializedNode.src !== undefined) { + writable.__src = serializedNode.src; + } + if (serializedNode.altText !== undefined) { + writable.__altText = serializedNode.altText; + } + return writable; + } + + createDOM(_config: EditorConfig): HTMLElement { + return document.createElement("span"); + } + + updateDOM(): false { + return false; + } + + decorate(): null { + return null; + } +} + +function $createCustomDecoratorNode({ + src = "", + altText = "", +}: { + src?: string; + altText?: string; +} = {}): CustomDecoratorNode { + return $applyNodeReplacement(new CustomDecoratorNode(src, altText)); +} + +async function createTwoParagraphRoom() { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.history.disable(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("First"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Second"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + return { room, document }; +} + +function createCollaborationFromDocument( + room: Room, + document: LiveRootNode +): { + editor: LexicalEditor; + collaboration: LiveblocksCollaboration; + manager: LiveblocksCollaborationManager; +} { + const editor = createLexicalEditor({ + namespace: "history-selection-test", + nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode], + }); + + // Mirror storage into Lexical, including LiveText format segments so mixed + // bold/plain spans bind as sibling TextNodes under one LiveText child. + editor.update( + () => { + for (const child of document.get("children")) { + const paragraph = $createParagraphNode(); + for (const grandchild of (child as LiveElementNode).get("children")) { + if (grandchild.get("kind") === "text") { + paragraph.append( + ...$createTextNodesFromLiveText( + (grandchild as LiveTextNode).get("content") + ) + ); + } + } + $getRoot().append(paragraph); + } + }, + { discrete: true } + ); + + const collaboration = new LiveblocksCollaboration(editor, room, document); + editor.update(() => {}, { discrete: true }); + collaboration.register(); + + return { + editor, + collaboration, + manager: collaboration.manager, + }; +} + +function $createTextNodesFromLiveText(content: LiveText): TextNode[] { + return content.toJSON().map((segment) => { + const node = $createTextNode(segment[0]); + const attributes = segment.length > 1 ? segment[1] : undefined; + if (attributes?.bold === true) { + node.toggleFormat("bold"); + } + if (attributes?.italic === true) { + node.toggleFormat("italic"); + } + if (attributes?.underline === true) { + node.toggleFormat("underline"); + } + if (attributes?.strikethrough === true) { + node.toggleFormat("strikethrough"); + } + if (attributes?.code === true) { + node.toggleFormat("code"); + } + return node; + }); +} + +async function createRoomWithFormattedText( + segments: ConstructorParameters[0] +) { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.history.disable(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(segments), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const content = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + + return { room, document, content }; +} + +async function createRoomWithText(text: string = "Hello") { + // Room setup uses real async I/O — must run before fake timers. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.history.disable(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(text), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const content = ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ).get("content"); + + return { room, document, content }; +} + +function createEditor( + text: string = "Hello", + extraNodes: Array> = [] +): LexicalEditor { + const editor = createLexicalEditor({ + namespace: "history-test", + nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode, ...extraNodes], + }); + editor.update( + () => { + $getRoot().append($createParagraphNode().append($createTextNode(text))); + }, + { discrete: true } + ); + return editor; +} diff --git a/packages/liveblocks-lexical/src/__tests__/manager.test.ts b/packages/liveblocks-lexical/src/__tests__/manager.test.ts new file mode 100644 index 00000000000..8f9a559e3ea --- /dev/null +++ b/packages/liveblocks-lexical/src/__tests__/manager.test.ts @@ -0,0 +1,8815 @@ +import { $createHeadingNode, HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { $dfs } from "@lexical/utils"; +import { + LiveList, + LiveMap, + LiveObject, + LiveText, + type Room, +} from "@liveblocks/client"; +import type { Json, TextAttributes } from "@liveblocks/core"; +import { kInternal } from "@liveblocks/core"; +import { + $applyNodeReplacement, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getNodeByKey, + $getRoot, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + $setSelection, + COLLABORATION_TAG, + createEditor as createLexicalEditor, + DecoratorNode, + type EditorConfig, + type ElementNode, + HISTORIC_TAG, + type LexicalEditor, + type LexicalNode, + type LexicalUpdateJSON, + type NodeKey, + ParagraphNode, + type SerializedLexicalNode, + type SerializedTextNode, + type Spread, + type TextModeType, + TextNode, +} from "lexical"; +import { describe, expect, test, vi } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup"; +import { + $getLexicalNodeProps, + $setLexicalNodeProps, + areTextNodesStructurallyEqual, + createStorageNodeFromLexicalNode, + find_liveblocksNode, + LiveblocksCollaborationManager, +} from "../manager"; +import type { + LiveDecoratorNode, + LiveElementNode, + LiveLineBreakNode, + LiveRootNode, + LiveStorageNode, + LiveTextNode, +} from "../types"; + +describe("LiveblocksCollaborationManager", () => { + describe("$encodeSelection", () => { + test("encodes a collapsed text caret", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello world"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text_lexical.select(3, 3); + }); + + editor.read(() => { + const textNodeId = text_liveblocks[kInternal].getId(); + expect(textNodeId).toBeDefined(); + const version = text_liveblocks.get("content").version; + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: textNodeId, + type: "text", + offset: 3, + version, + }, + focus: { + nodeId: textNodeId, + type: "text", + offset: 3, + version, + }, + }); + }); + }); + + test("encodes a non-collapsed text range within a single TextNode", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello world"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text_lexical.select(1, 5); + }); + + editor.read(() => { + const textNodeId = text_liveblocks[kInternal].getId(); + const version = text_liveblocks.get("content").version; + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: textNodeId, + type: "text", + offset: 1, + version, + }, + focus: { + nodeId: textNodeId, + type: "text", + offset: 5, + version, + }, + }); + }); + }); + + test("flattens offsets across coalesced TextNodes that share one LiveText", async () => { + // One LiveText with two segments → two Lexical TextNodes, one binding. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([ + ["Hello ", { bold: true }], + ["world"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + expect(textNodes).toHaveLength(2); + // Caret in "world" at local offset 1 → flat LiveText offset 7. + textNodes[1]!.select(1, 1); + }); + + editor.read(() => { + const textNodeId = text_liveblocks[kInternal].getId(); + const version = text_liveblocks.get("content").version; + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: textNodeId, + type: "text", + offset: 7, + version, + }, + focus: { + nodeId: textNodeId, + type: "text", + offset: 7, + version, + }, + }); + }); + }); + + test("encodes a range spanning coalesced TextNodes into flat LiveText offsets", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([ + ["Hello ", { bold: true }], + ["world"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + const selection = $createRangeSelection(); + // "Hell|o " … "wo|rld" → flat [4, 8] + selection.anchor.set(textNodes[0]!.getKey(), 4, "text"); + selection.focus.set(textNodes[1]!.getKey(), 2, "text"); + $setSelection(selection); + }); + + editor.read(() => { + const textNodeId = text_liveblocks[kInternal].getId(); + const version = text_liveblocks.get("content").version; + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: textNodeId, + type: "text", + offset: 4, + version, + }, + focus: { + nodeId: textNodeId, + type: "text", + offset: 8, + version, + }, + }); + }); + }); + + test("does not accumulate offsets across adjacent distinct LiveText children", async () => { + // Concurrent remote inserts can leave two separate LiveText children + // whose Lexical TextNodes sit next to each other. Formats differ so + // Lexical does not merge them. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["foo", { bold: true }]]), + }), + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("bar"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const second_liveblocks = (paragraph_liveblocks as LiveElementNode) + .get("children") + .get(1)! as LiveTextNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + expect(textNodes).toHaveLength(2); + // Caret inside "bar" at offset 1 — must NOT include "foo"'s length. + textNodes[1]!.select(1, 1); + }); + + editor.read(() => { + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(encoded!.anchor).toEqual({ + nodeId: second_liveblocks[kInternal].getId(), + type: "text", + offset: 1, + version: second_liveblocks.get("content").version, + }); + expect(encoded!.focus).toEqual(encoded!.anchor); + }); + }); + + test("encodes an element point, coalescing TextNodes that share one LiveText", async () => { + // Lexical: [Text "Hi" bold, Text "there", LineBreak] + // Storage: [text (coalesced), linebreak] + // Element caret after both text nodes (Lexical index 2) → storage offset 1. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["Hi", { bold: true }], ["there"]]), + }), + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(3); + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 2, "element"); + selection.focus.set(paragraph.getKey(), 2, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + }); + }); + }); + + test("encodes an element point between adjacent distinct LiveText children", async () => { + // Lexical: [Text "foo" bold, Text "bar"] — two storage text children. + // Element caret between them (Lexical index 1) → storage offset 1, + // not 0 (would happen if all adjacent text were blindly coalesced). + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["foo", { bold: true }]]), + }), + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("bar"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 1, "element"); + selection.focus.set(paragraph.getKey(), 1, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + }); + }); + }); + + test("encodes an element point at the end of a paragraph", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + // Children: [Text, LineBreak] → end is Lexical index 2 → storage 2. + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 2, "element"); + selection.focus.set(paragraph.getKey(), 2, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 2, + version: 0, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 2, + version: 0, + }, + }); + }); + }); + + test("returns null when the selected text node is unbound", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const extra = $createTextNode("extra"); + paragraph.append(extra); + extra.select(0, 0); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toBeNull(); + }); + }); + + test("returns null when an element point crosses an unbound text child", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append($createTextNode("extra")); + // Element caret after the unbound text (Lexical index 2). + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 2, "element"); + selection.focus.set(paragraph.getKey(), 2, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toBeNull(); + }); + }); + + test("returns null when there is no range selection", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + editor.read(() => { + expect(manager.$encodeSelection()).toBeNull(); + }); + }); + + test("encodes through LiveText.encodeIndex after a local pending insert", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const liveText = text_liveblocks.get("content"); + + // Local pending insert at index 0 shifts local "H|ello" caret without + // bumping the confirmed version. encodeIndex must report confirmed coords. + liveText.insert(0, "X"); + const version = liveText.version; + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + // Mirror the local LiveText content in Lexical and place caret after "X". + text_lexical.setTextContent(liveText.toString()); + text_lexical.select(1, 1); + }); + + editor.read(() => { + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + // Local caret at 1 (after pending "X") → confirmed offset 0. + expect(encoded!.anchor.offset).toBe(liveText[kInternal].encodeIndex(1)); + expect(encoded!.anchor.offset).toBe(0); + expect(encoded!.anchor.version).toBe(version); + expect(encoded!.anchor.nodeId).toBe(text_liveblocks[kInternal].getId()); + }); + }); + + test("returns null when storage nodes are detached (no node id)", () => { + // createParagraphDocument builds LiveObjects that never enter a room + // pool, so getId() is undefined and presence cannot be published. + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text_lexical.select(2, 2); + }); + + editor.read(() => { + expect(text_liveblocks[kInternal].getId()).toBeUndefined(); + expect(manager.$encodeSelection()).toBeNull(); + }); + }); + + test("encodes a range spanning two root paragraphs", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const first_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveTextNode; + const second_liveblocks = ( + document.get("children").get(1) as LiveElementNode + ) + .get("children") + .get(0)! as LiveTextNode; + + editor.update(() => { + const paragraphs = $dfs() + .filter(({ node }) => $isParagraphNode(node)) + .map(({ node }) => node as ParagraphNode); + expect(paragraphs).toHaveLength(2); + const firstText = paragraphs[0]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const secondText = paragraphs[1]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const selection = $createRangeSelection(); + selection.anchor.set(firstText.getKey(), 1, "text"); + selection.focus.set(secondText.getKey(), 2, "text"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: first_liveblocks[kInternal].getId(), + type: "text", + offset: 1, + version: first_liveblocks.get("content").version, + }, + focus: { + nodeId: second_liveblocks[kInternal].getId(), + type: "text", + offset: 2, + version: second_liveblocks.get("content").version, + }, + }); + }); + }); + }); + + describe("$decodeSelection", () => { + test("round-trips a collapsed text caret", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello world"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text_lexical.select(3, 3); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: text_lexical.getKey(), offset: 3, type: "text" }, + focus: { key: text_lexical.getKey(), offset: 3, type: "text" }, + }); + }); + }); + + test("round-trips a caret inside coalesced text", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([ + ["Hello ", { bold: true }], + ["world"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + textNodes[1]!.select(1, 1); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const secondText = paragraph + .getChildren() + .filter($isTextNode)[1] as TextNode; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: secondText.getKey(), offset: 1, type: "text" }, + focus: { key: secondText.getKey(), offset: 1, type: "text" }, + }); + }); + }); + + test("round-trips a non-collapsed range across coalesced segments", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([ + ["Hello ", { bold: true }], + ["world"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + const selection = $createRangeSelection(); + selection.anchor.set(textNodes[0]!.getKey(), 4, "text"); + selection.focus.set(textNodes[1]!.getKey(), 2, "text"); + $setSelection(selection); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: textNodes[0]!.getKey(), offset: 4, type: "text" }, + focus: { key: textNodes[1]!.getKey(), offset: 2, type: "text" }, + }); + }); + }); + + test("round-trips a caret in the second of adjacent distinct LiveText children", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["foo", { bold: true }]]), + }), + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("bar"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + textNodes[1]!.select(1, 1); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: textNodes[1]!.getKey(), offset: 1, type: "text" }, + focus: { key: textNodes[1]!.getKey(), offset: 1, type: "text" }, + }); + }); + }); + + test("round-trips an element point after coalesced text", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["Hi", { bold: true }], ["there"]]), + }), + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 2, "element"); + selection.focus.set(paragraph.getKey(), 2, "element"); + $setSelection(selection); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const encoded = manager.$encodeSelection(); + expect(encoded).toEqual({ + anchor: { + nodeId: document.get("children").get(0)![kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + focus: { + nodeId: document.get("children").get(0)![kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + }); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: paragraph.getKey(), offset: 2, type: "element" }, + focus: { key: paragraph.getKey(), offset: 2, type: "element" }, + }); + }); + }); + + test("round-trips an element point between adjacent distinct LiveText children", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([["foo", { bold: true }]]), + }), + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("bar"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 1, "element"); + selection.focus.set(paragraph.getKey(), 1, "element"); + $setSelection(selection); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(encoded!.anchor.offset).toBe(1); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: paragraph.getKey(), offset: 1, type: "element" }, + focus: { key: paragraph.getKey(), offset: 1, type: "element" }, + }); + }); + }); + + test("returns null when the storage node id is unknown", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.read(() => { + expect( + manager.$decodeSelection({ + anchor: { + nodeId: "missing-node-id", + type: "text", + offset: 0, + version: 0, + }, + focus: { + nodeId: "missing-node-id", + type: "text", + offset: 0, + version: 0, + }, + }) + ).toBeNull(); + }); + }); + + test("returns null when LiveText version is ahead of local state", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.read(() => { + const textNodeId = text_liveblocks[kInternal].getId(); + expect( + manager.$decodeSelection({ + anchor: { + nodeId: textNodeId!, + type: "text", + offset: 1, + version: text_liveblocks.get("content").version + 1, + }, + focus: { + nodeId: textNodeId!, + type: "text", + offset: 1, + version: text_liveblocks.get("content").version + 1, + }, + }) + ).toBeNull(); + }); + }); + + test("returns null when the point type does not match the storage node", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.read(() => { + expect( + manager.$decodePoint({ + nodeId: paragraph_liveblocks[kInternal].getId()!, + type: "text", + offset: 0, + version: 0, + }) + ).toBeNull(); + }); + }); + + test("returns null when the LiveText binding is empty", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.read(() => { + expect(manager.binding.forward.get(text_liveblocks)).toEqual([]); + expect( + manager.$decodePoint({ + nodeId: text_liveblocks[kInternal].getId()!, + type: "text", + offset: 0, + version: text_liveblocks.get("content").version, + }) + ).toBeNull(); + }); + }); + + test("returns null when coalesced TextNode bindings are detached", async () => { + // Simulates mid-reconcile / post-delete forward map still holding + // TextNode refs whose keys are gone from the active editor state. + // Decode must return null — not throw via getLatest(). + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + let encoded: NonNullable>; + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.select(2, 2); + }); + + editor.read(() => { + encoded = manager.$encodeSelection()!; + expect(encoded).not.toBeNull(); + }); + + // Detach the TextNode without refreshing bindings — same shape as a + // remote-cursor decode racing a structural delete. + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.remove(); + }); + + editor.read(() => { + expect(() => manager.$decodeSelection(encoded!)).not.toThrow(); + expect(manager.$decodeSelection(encoded!)).toBeNull(); + }); + }); + + test("returns null when element bindings are detached", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + let encoded: NonNullable>; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ElementNode; + const selection = $createRangeSelection(); + selection.anchor.set(paragraph.getKey(), 0, "element"); + selection.focus.set(paragraph.getKey(), 0, "element"); + $setSelection(selection); + }); + + editor.read(() => { + const selection = manager.$encodeSelection(); + expect(selection).not.toBeNull(); + encoded = selection!.anchor; + }); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ElementNode; + paragraph.remove(); + }); + + editor.read(() => { + expect(() => manager.$decodePoint(encoded!)).not.toThrow(); + expect(manager.$decodePoint(encoded!)).toBeNull(); + }); + }); + + test("keeps a coalesced segment-boundary offset on the earlier TextNode", async () => { + // flatOffset === size of first TextNode must decode to the END of t0, + // not the start of t1 — locks the `>` (not `>=`) walk in $decodeTextPoint. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText([ + ["Hello ", { bold: true }], + ["world"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + expect(textNodes).toHaveLength(2); + expect(textNodes[0]!.getTextContentSize()).toBe(6); + // Caret at end of "Hello " (local offset 6) → flat LiveText offset 6. + textNodes[0]!.select(6, 6); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNodes = paragraph + .getChildren() + .filter($isTextNode) as TextNode[]; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(encoded!.anchor.offset).toBe(6); + + const decoded = manager.$decodeSelection(encoded!); + expect(decoded).toEqual({ + anchor: { + key: textNodes[0]!.getKey(), + offset: 6, + type: "text", + }, + focus: { + key: textNodes[0]!.getKey(), + offset: 6, + type: "text", + }, + }); + }); + }); + + test("round-trips through encodeIndex/decodeIndex after a local pending insert", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const liveText = text_liveblocks.get("content"); + + // Local pending insert at 0: local doc is "XHello", confirmed still "Hello". + liveText.insert(0, "X"); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text_lexical.setTextContent(liveText.toString()); + // Caret after pending "X" (local offset 1). + text_lexical.select(1, 1); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + // Presence carries confirmed coords (offset 0). + expect(encoded!.anchor.offset).toBe(0); + + // decodeIndex remaps confirmed 0 → local 1 (after the pending insert). + const decoded = manager.$decodeSelection(encoded!); + expect(decoded).toEqual({ + anchor: { key: text_lexical.getKey(), offset: 1, type: "text" }, + focus: { key: text_lexical.getKey(), offset: 1, type: "text" }, + }); + }); + }); + + test("round-trips a range spanning two root paragraphs", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraphs = $dfs() + .filter(({ node }) => $isParagraphNode(node)) + .map(({ node }) => node as ParagraphNode); + const firstText = paragraphs[0]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const secondText = paragraphs[1]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const selection = $createRangeSelection(); + selection.anchor.set(firstText.getKey(), 1, "text"); + selection.focus.set(secondText.getKey(), 2, "text"); + $setSelection(selection); + }); + + editor.read(() => { + const paragraphs = $dfs() + .filter(({ node }) => $isParagraphNode(node)) + .map(({ node }) => node as ParagraphNode); + const firstText = paragraphs[0]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const secondText = paragraphs[1]! + .getChildren() + .filter($isTextNode)[0] as TextNode; + const encoded = manager.$encodeSelection(); + expect(encoded).not.toBeNull(); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: firstText.getKey(), offset: 1, type: "text" }, + focus: { key: secondText.getKey(), offset: 2, type: "text" }, + }); + }); + }); + + test("round-trips a mixed text + element selection across a linebreak", async () => { + // Anchor in text, focus as an element point after the text slot + // (before the linebreak). Exercises encode/decode with different + // endpoint types in one selection. + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hello"), + }), + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const text_liveblocks = (paragraph_liveblocks as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode; + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNode = paragraph + .getChildren() + .filter($isTextNode)[0] as TextNode; + // Lexical: [Text "Hello", LineBreak] — element index 1 is before br. + const selection = $createRangeSelection(); + selection.anchor.set(textNode.getKey(), 2, "text"); + selection.focus.set(paragraph.getKey(), 1, "element"); + $setSelection(selection); + }); + + editor.read(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + const textNode = paragraph + .getChildren() + .filter($isTextNode)[0] as TextNode; + + const encoded = manager.$encodeSelection(); + expect(encoded).toEqual({ + anchor: { + nodeId: text_liveblocks[kInternal].getId(), + type: "text", + offset: 2, + version: text_liveblocks.get("content").version, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + }); + + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: textNode.getKey(), offset: 2, type: "text" }, + focus: { key: paragraph.getKey(), offset: 1, type: "element" }, + }); + }); + }); + + test("encodes an element point before and after a decorator sibling", async () => { + // Lexical: [Text "Hi", Decorator, Text "!"] + // Storage: [text "Hi", decorator, text "!"] + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("!"), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(3); + const selection = $createRangeSelection(); + // Before the decorator (after "Hi"). + selection.anchor.set(paragraph.getKey(), 1, "element"); + selection.focus.set(paragraph.getKey(), 1, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 1, + version: 0, + }, + }); + }); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const selection = $createRangeSelection(); + // After the decorator (before "!"). + selection.anchor.set(paragraph.getKey(), 2, "element"); + selection.focus.set(paragraph.getKey(), 2, "element"); + $setSelection(selection); + }); + + editor.read(() => { + expect(manager.$encodeSelection()).toEqual({ + anchor: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 2, + version: 0, + }, + focus: { + nodeId: paragraph_liveblocks[kInternal].getId(), + type: "element", + offset: 2, + version: 0, + }, + }); + }); + }); + + test("round-trips a text caret beside a decorator sibling", async () => { + const { room, root } = (await prepareIsolatedStorageTest( + [createSerializedRoot()], + 0 + )) as unknown as { + room: Room; + root: LiveObject<{ document?: LiveRootNode }>; + }; + + room.batch(() => { + root.set( + "document", + new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) + ); + }); + + const document = root.get("document") as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const text_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveTextNode; + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 2, "text"); + selection.focus.set(text.getKey(), 2, "text"); + $setSelection(selection); + }); + + editor.read(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const encoded = manager.$encodeSelection(); + expect(encoded).toEqual({ + anchor: { + nodeId: text_liveblocks[kInternal].getId(), + type: "text", + offset: 2, + version: text_liveblocks.get("content").version, + }, + focus: { + nodeId: text_liveblocks[kInternal].getId(), + type: "text", + offset: 2, + version: text_liveblocks.get("content").version, + }, + }); + expect(manager.$decodeSelection(encoded!)).toEqual({ + anchor: { key: text.getKey(), offset: 2, type: "text" }, + focus: { key: text.getKey(), offset: 2, type: "text" }, + }); + }); + }); + }); + + describe("$reconcileTextNode", () => { + test("is a no-op on LiveText when content already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const contentBefore = text_liveblocks.get("content").toJSON(); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual(contentBefore); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([ + text_lexical, + ]); + expect(manager.binding.reverse.get(text_lexical.getKey())).toBe( + text_liveblocks + ); + }); + }); + + test("uses a single LiveText replace when content changes", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setTextContent("First pasted"); + }); + + editor.read(() => { + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const liveText = text_liveblocks.get("content"); + const replaceSpy = vi.spyOn(liveText, "replace"); + const deleteSpy = vi.spyOn(liveText, "delete"); + const insertSpy = vi.spyOn(liveText, "insert"); + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).not.toHaveBeenCalled(); + expect(insertSpy).not.toHaveBeenCalled(); + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["First pasted"], + ]); + }); + }); + + test("clears LiveText when Lexical text is emptied", () => { + const document = createParagraphDocument("Delete me"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setTextContent(""); + }); + + editor.read(() => { + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([]); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([]); + }); + }); + + test("synchronizes bold formatting when plain text already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setFormat(1); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!", { bold: true }], + ]); + }); + }); + + test("updates LiveText when content changes in the middle", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setTextContent("Hi world!"); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hi world!"], + ]); + }); + }); + + test("appends text to LiveText", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setTextContent("Hello world!"); + }); + + editor.read(() => { + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()), + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!"], + ]); + }); + }); + + test("persists TextNode subclass type as segment attribute type", () => { + const document = createParagraphDocument("entity"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.replace($createCustomTextNode(text.getTextContent())); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("custom-text"); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["entity", { type: "custom-text" }], + ]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("persists mixed plain and TextNode subclass siblings with t only on the subclass", () => { + const document = createParagraphDocument("Hello entity"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + paragraph.clear(); + paragraph.append( + $createTextNode("Hello "), + $createCustomTextNode("entity") + ); + }); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical(textNodes, text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello "], + ["entity", { type: "custom-text" }], + ]); + expect(areTextNodesStructurallyEqual(text_liveblocks, textNodes)).toBe( + true + ); + }); + }); + + test("persists mixed plain and subclass siblings with exportJSON field only on the subclass", () => { + const document = createParagraphDocument("Hello import"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + paragraph.clear(); + paragraph.append( + $createTextNode("Hello "), + $createCustomTextNode("import", "keyword") + ); + }); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical(textNodes, text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello "], + ["import", { type: "custom-text", highlightType: "keyword" }], + ]); + expect(areTextNodesStructurallyEqual(text_liveblocks, textNodes)).toBe( + true + ); + }); + }); + + test("clears segment attribute type when a TextNode subclass becomes plain text", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([["entity", { type: "custom-text" }]]), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text.getType()).toBe("custom-text"); + text.replace($createTextNode(text.getTextContent())); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("text"); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([["entity"]]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("clears type and exportJSON fields together when a subclass becomes plain text", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.replace($createTextNode(text.getTextContent())); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([["import"]]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("persists TextNode subclass type together with inline format", () => { + const document = createParagraphDocument("entity"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const custom = $createCustomTextNode(text.getTextContent()); + custom.toggleFormat("bold"); + text.replace(custom); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("custom-text"); + expect(text_lexical.getFormat()).toBe(1); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["entity", { bold: true, type: "custom-text" }], + ]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("persists TextNode subclass exportJSON field as a top-level segment attribute", () => { + const document = createParagraphDocument("import"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.replace($createCustomTextNode(text.getTextContent(), "keyword")); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text_lexical.getHighlightType()).toBe("keyword"); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("clears TextNode subclass exportJSON field when it becomes unset", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text.getHighlightType()).toBe("keyword"); + text.setHighlightType(undefined); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text_lexical.getHighlightType()).toBeUndefined(); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["import", { type: "custom-text" }], + ]); + expect( + areTextNodesStructurallyEqual(text_liveblocks, [text_lexical]) + ).toBe(true); + }); + }); + + test("persists exportJSON field together with inline format and subclass type", () => { + const document = createParagraphDocument("import"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const custom = $createCustomTextNode(text.getTextContent(), "keyword"); + custom.toggleFormat("bold"); + text.replace(custom); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + [ + "import", + { bold: true, type: "custom-text", highlightType: "keyword" }, + ], + ]); + }); + }); + + test("syncs coalesced sibling TextNodes into LiveText segments", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))! + .node as ParagraphNode; + paragraph.clear(); + const bold = $createTextNode("Hello "); + bold.toggleFormat("bold"); + paragraph.append(bold, $createTextNode("world")); + }); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical(textNodes, text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello ", { bold: true }], + ["world"], + ]); + expect(manager.binding.forward.get(text_liveblocks)).toEqual(textNodes); + }); + }); + + test("splits LiveText when one TextNode becomes two siblings with the same plain text", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const [, second] = text.splitText(6); + second.toggleFormat("bold"); + }); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical(textNodes, text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello "], + ["world", { bold: true }], + ]); + }); + }); + + test("keeps plain text when one TextNode is split into two unformatted siblings", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + text.splitText(6); + }); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => node.getLatest()); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical(textNodes, text_liveblocks); + + const segments = text_liveblocks.get("content").toJSON(); + expect(segments.map((segment) => segment[0]).join("")).toBe( + "Hello world" + ); + // LiveText may coalesce same-format spans into one segment (unlike Yjs + // deltas, which stay 1:1 with TextNodes via the `i` attribute). + expect(segments.length).toBeLessThanOrEqual(2); + }); + }); + + test("synchronizes mode when plain text already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setMode("token"); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!", { mode: "token" }], + ]); + }); + }); + + test("clears mode from LiveText when reset to the Lexical default", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([["Hello world!", { mode: "token" }]]), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setMode("normal"); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!"], + ]); + }); + }); + + test("omits default TextNode exportJSON fields from LiveText segments", () => { + const document = createParagraphDocument("hello"); + const { editor, manager } = createEditor(document); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.exportJSON()).toMatchObject({ + type: "text", + text: "hello", + format: 0, + detail: 0, + mode: "normal", + style: "", + }); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical([text_lexical], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([["hello"]]); + }); + }); + + test("synchronizes style when plain text already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setStyle("color: red"); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!", { style: "color: red" }], + ]); + }); + }); + + test("uses a minimal LiveText replace when inserting in the middle", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + editor.update(() => { + ( + $dfs().find(({ node }) => $isTextNode(node))!.node as TextNode + ).setTextContent("Hello Xworld"); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const liveText = text_liveblocks.get("content"); + const replaceSpy = vi.spyOn(liveText, "replace"); + + manager.$reconcileTextNodeFromLexical( + [text_lexical.getLatest()], + text_liveblocks + ); + + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledWith(6, 0, "X", undefined); + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello Xworld"], + ]); + }); + }); + + test("clears LiveText when all Lexical text nodes are detached", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + let detached: TextNode; + editor.update(() => { + detached = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + detached.remove(); + }); + + editor.read(() => { + manager.$reconcileTextNodeFromLexical([detached!], text_liveblocks); + + expect(text_liveblocks.get("content").toJSON()).toEqual([]); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([]); + }); + }); + }); + + describe("$reconcileElementNodeFromLexical", () => { + test("is a no-op when element children already match", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const childrenBefore = paragraph_liveblocks.get("children").length; + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ElementNode; + + manager.$reconcileElementNodeFromLexical( + paragraph_lexical, + paragraph_liveblocks, + new Set() + ); + + expect(paragraph_liveblocks.get("children").length).toBe( + childrenBefore + ); + expect( + (paragraph_liveblocks.get("children").get(0)! as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([["Hello world!"]]); + }); + }); + + test("syncs element type and props onto the storage node", () => { + const document = createParagraphDocument("Title"); + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + editor.update( + () => { + const heading = $createHeadingNode("h2"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }, + { discrete: true } + ); + + editor.read(() => { + manager.$reconcileElementNodeFromLexical( + $getRoot().getFirstChild() as ElementNode, + paragraph_liveblocks, + new Set([$getRoot().getFirstChild()!.getKey()]) + ); + }); + + expect(paragraph_liveblocks.get("type")).toBe("heading"); + expect(paragraph_liveblocks.get("props")?.toJSON()).toEqual({ + tag: "h2", + }); + }); + + test("does not treat storage with extra children as structurally equal", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + paragraph_liveblocks.get("children").push( + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ElementNode; + expect(() => { + manager.$reconcileElementNodeFromLexical( + paragraph_lexical, + paragraph_liveblocks, + new Set() + ); + }).not.toThrow(); + }); + }); + }); + + describe("$applyLocalUpdates", () => { + test("syncs append typing to LiveText", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + const text_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveTextNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + for (const char of " world!") { + editor.update( + () => { + const text = ( + $getRoot().getFirstChild() as ParagraphNode + ).getFirstChild()!; + const selection = text.selectEnd(); + if (!$isRangeSelection(selection)) { + throw new Error("expected range selection"); + } + selection.insertText(char); + }, + { discrete: true } + ); + } + unregister(); + + expect(text_liveblocks.get("content").toJSON()).toEqual([ + ["Hello world!"], + ]); + }); + + test("inserts a new paragraph at the end of the root", () => { + const document = createParagraphDocument("One"); + const { editor, manager } = createEditor(document); + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("Two")) + ); + }, + { discrete: true } + ); + unregister(); + + expect(document.get("children").length).toBe(2); + expect( + ( + (document.get("children").get(1) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Two"]]); + }); + + test("preserves document order when inserting multiple paragraphs at once", () => { + const document = createParagraphDocument(""); + const { editor, manager } = createEditor(document); + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const root = $getRoot(); + root.clear(); + root.append( + $createParagraphNode().append($createTextNode("P1")), + $createParagraphNode().append($createTextNode("P2")), + $createParagraphNode().append($createTextNode("P3")) + ); + }, + { discrete: true } + ); + unregister(); + + const children = document.get("children"); + expect(children.length).toBe(3); + expect( + ( + (children.get(0)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P1"]]); + expect( + ( + (children.get(1)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P2"]]); + expect( + ( + (children.get(2)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P3"]]); + }); + + test("preserves document order when appending multiple paragraphs at once", () => { + const document = createParagraphDocument("P1"); + const { editor, manager } = createEditor(document); + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("P2")), + $createParagraphNode().append($createTextNode("P3")), + $createParagraphNode().append($createTextNode("P4")) + ); + }, + { discrete: true } + ); + unregister(); + + const children = document.get("children"); + expect(children.length).toBe(4); + expect( + ( + (children.get(0)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P1"]]); + expect( + ( + (children.get(1)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P2"]]); + expect( + ( + (children.get(2)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P3"]]); + expect( + ( + (children.get(3)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P4"]]); + }); + + test("preserves document order when inserting multiple paragraphs in the middle", () => { + const document = createParagraphDocument("P1"); + const { editor, manager } = createEditor(document); + + const unregisterAppend = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("P4")) + ); + }, + { discrete: true } + ); + unregisterAppend(); + + const unregisterInsert = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const midA = $createParagraphNode().append($createTextNode("P2")); + const midB = $createParagraphNode().append($createTextNode("P3")); + $getRoot().getChildAtIndex(1)!.insertBefore(midA); + midA.insertAfter(midB); + }, + { discrete: true } + ); + unregisterInsert(); + + const children = document.get("children"); + expect(children.length).toBe(4); + expect( + ( + (children.get(0)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P1"]]); + expect( + ( + (children.get(1)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P2"]]); + expect( + ( + (children.get(2)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P3"]]); + expect( + ( + (children.get(3)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["P4"]]); + }); + + test("syncs insertText after clearing all content", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + const dirtyRoots: boolean[] = []; + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + dirtyRoots.push(dirtyElements.has("root")); + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }, + { discrete: true } + ); + + for (const char of "Hi") { + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const selection = paragraph.selectEnd(); + if (!$isRangeSelection(selection)) { + throw new Error("expected range selection"); + } + selection.insertText(char); + }, + { discrete: true } + ); + } + unregister(); + + expect(dirtyRoots.every(Boolean)).toBe(true); + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Hi"]]); + }); + + test("syncs typing into a pasted blank paragraph", () => { + const document = createParagraphDocument("A"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + sync(() => { + $getRoot().append( + $createParagraphNode(), + $createParagraphNode().append($createTextNode("B")) + ); + }); + + expect( + (document.get("children").get(1) as LiveElementNode).get("children") + .length + ).toBe(0); + + sync(() => { + ($getRoot().getChildAtIndex(1) as ParagraphNode).append( + $createTextNode("MID") + ); + }); + + expect( + (document.get("children").get(1) as LiveElementNode).get("children") + .length + ).toBe(1); + expect( + ( + (document.get("children").get(1) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["MID"]]); + }); + + test("syncs typing into a pasted empty paragraph after delete-all", () => { + const document = createParagraphDocument("Keep"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + // Simulate paste with blank line → empty paragraph (no text child in Lexical) + sync(() => { + $getRoot().append( + $createParagraphNode(), + $createParagraphNode().append($createTextNode("Tail")) + ); + }); + + expect( + (document.get("children").get(1) as LiveElementNode).get("children") + .length + ).toBe(0); + + sync(() => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }); + + // Whatever storage shape delete-all left, typing must sync again. + sync(() => { + ($getRoot().getFirstChild() as ParagraphNode).append( + $createTextNode("Recovered") + ); + }); + + const children = document.get("children"); + expect(children.length).toBe(1); + expect((children.get(0)! as LiveElementNode).get("children").length).toBe( + 1 + ); + expect( + ( + (children.get(0)! as LiveElementNode) + .get("children") + .get(0) as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Recovered"]]); + }); + + test("retains empty LiveText after deleting all paragraphs", () => { + const document = createParagraphDocument("P1"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + sync(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode("P2")), + $createParagraphNode().append($createTextNode("P3")) + ); + }); + + sync(() => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }); + + expect(document.get("children").length).toBe(1); + const paragraph = document.get("children").get(0) as LiveElementNode; + expect(paragraph.get("children").length).toBe(1); + expect(paragraph.get("children").get(0)!.get("kind")).toBe("text"); + expect( + (paragraph.get("children").get(0) as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([]); + expect(manager.binding.reverse.size).toBeGreaterThan(0); + + sync(() => { + ($getRoot().getFirstChild() as ParagraphNode).append( + $createTextNode("After delete") + ); + }); + + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["After delete"]]); + }); + + test("keeps an empty LiveText child after select-all delete", () => { + const document = createParagraphDocument("Hello world"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + // Multi-paragraph then select-all style clear to a single empty paragraph + sync(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode("Second")) + ); + }); + + sync(() => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }); + + expect(document.get("children").length).toBe(1); + const paragraph = document.get("children").get(0) as LiveElementNode; + expect(paragraph.get("children").length).toBe(1); + expect(paragraph.get("children").get(0)!.get("kind")).toBe("text"); + expect( + (paragraph.get("children").get(0) as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([]); + + sync(() => { + ($getRoot().getFirstChild() as ParagraphNode).append( + $createTextNode("Again") + ); + }); + + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Again"]]); + }); + + test("survives clearing the document then typing", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + sync(() => { + $getRoot().clear(); + $getRoot().append($createParagraphNode()); + }); + + expect(document.get("children").length).toBe(1); + expect(manager.binding.reverse.size).toBeGreaterThan(0); + + sync(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append($createTextNode("After")); + }); + + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["After"]]); + }); + + test("survives emptying the root then appending a paragraph", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const sync = (fn: () => void) => { + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + editor.update(fn, { discrete: true }); + unregister(); + }; + + sync(() => { + $getRoot().clear(); + }); + + expect(document.get("children").length).toBe(0); + expect(manager.binding.reverse.size).toBeGreaterThan(0); + + sync(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode("After")) + ); + }); + + expect(document.get("children").length).toBe(1); + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["After"]]); + }); + + test("deletes a root paragraph when Lexical removes it", () => { + const document = createParagraphDocument("One"); + const { editor, manager } = createEditor(document); + + const unregisterInsert = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("Two")) + ); + }, + { discrete: true } + ); + unregisterInsert(); + + const unregisterDelete = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraphs = $getRoot().getChildren(); + paragraphs[1]!.remove(); + }, + { discrete: true } + ); + unregisterDelete(); + + expect(document.get("children").length).toBe(1); + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["One"]]); + }); + + test("inserts a paragraph between existing root children", () => { + const document = createParagraphDocument("One"); + const { editor, manager } = createEditor(document); + + const unregisterAppend = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode("Three")) + ); + }, + { discrete: true } + ); + unregisterAppend(); + + const unregisterInsert = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const second = $createParagraphNode().append($createTextNode("Two")); + $getRoot().getChildAtIndex(1)!.insertBefore(second); + }, + { discrete: true } + ); + unregisterInsert(); + + expect(document.get("children").length).toBe(3); + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["One"]]); + expect( + ( + (document.get("children").get(1) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Two"]]); + expect( + ( + (document.get("children").get(2) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["Three"]]); + }); + + test("replaces a root storage child when the Lexical node type changes", () => { + const document = createParagraphDocument("Title"); + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const heading = $createHeadingNode("h1"); + heading.append(...paragraph.getChildren()); + paragraph.replace(heading); + }, + { discrete: true } + ); + unregister(); + + editor.read(() => { + const heading_lexical = $getRoot().getFirstChild() as ElementNode; + + const rootChild = document.get("children").get(0) as LiveElementNode; + expect(rootChild).not.toBe(paragraph_liveblocks); + expect(rootChild.get("type")).toBe("heading"); + expect( + (rootChild.get("children").get(0)! as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([["Title"]]); + expect(manager.binding.reverse.get(heading_lexical.getKey())).toBe( + rootChild + ); + }); + }); + + test("preserves bindings for unchanged prefix and suffix root children", () => { + const document: LiveRootNode = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("A"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("B"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("C"), + }), + ]), + }), + ]), + }); + const { editor, manager } = createEditor(document); + const first_liveblocks = document.get("children").get(0)!; + const third_liveblocks = document.get("children").get(2)!; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const middle = $dfs().find( + ({ node }) => + $isParagraphNode(node) && node.getTextContent() === "B" + )!.node as ParagraphNode; + middle.remove(); + }, + { discrete: true } + ); + unregister(); + + editor.read(() => { + const first_lexical = $getRoot().getChildAtIndex(0) as ElementNode; + const second_lexical = $getRoot().getChildAtIndex(1) as ElementNode; + + expect(document.get("children").length).toBe(2); + expect( + ( + (document.get("children").get(0) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["A"]]); + expect( + ( + (document.get("children").get(1) as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([["C"]]); + expect(manager.binding.forward.get(first_liveblocks)).toBe( + first_lexical + ); + expect(manager.binding.forward.get(third_liveblocks)).toBe( + second_lexical + ); + expect(manager.binding.reverse.get(first_lexical.getKey())).toBe( + first_liveblocks + ); + expect(manager.binding.reverse.get(second_lexical.getKey())).toBe( + third_liveblocks + ); + }); + }); + + test("rebinds structurally equal storage children after Lexical node recreation", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const text_liveblocks = paragraph_liveblocks + .get("children") + .get(0)! as LiveTextNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const recreated = $createParagraphNode().append( + $createTextNode("Hello") + ); + paragraph.replace(recreated); + }, + { discrete: true } + ); + unregister(); + + editor.read(() => { + const recreated = $getRoot().getFirstChild() as ParagraphNode; + const text_lexical = recreated.getFirstChild() as TextNode; + + expect(manager.binding.forward.get(paragraph_liveblocks)).toBe( + recreated.getLatest() + ); + expect(manager.binding.reverse.get(recreated.getKey())).toBe( + paragraph_liveblocks + ); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([ + text_lexical, + ]); + expect(manager.binding.reverse.get(text_lexical.getKey())).toBe( + text_liveblocks + ); + }); + }); + + test("inserts a decorator child into storage", () => { + const document = createParagraphDocument("Hi"); + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }, + { discrete: true } + ); + unregister(); + + expect(paragraph_liveblocks.get("children").length).toBe(2); + const decorator_liveblocks = paragraph_liveblocks + .get("children") + .get(1)! as LiveDecoratorNode; + expect(decorator_liveblocks.get("kind")).toBe("decorator"); + expect(decorator_liveblocks.get("type")).toBe("custom-decorator"); + expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + expect(manager.binding.reverse.get(decorator.getKey())).toBe( + decorator_liveblocks + ); + }); + }); + + test("deletes a decorator child from storage", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + decorator.remove(); + }, + { discrete: true } + ); + unregister(); + + expect(paragraph_liveblocks.get("children").length).toBe(1); + expect(paragraph_liveblocks.get("children").get(0)!.get("kind")).toBe( + "text" + ); + }); + + test("updates decorator props in place without recreating the LiveObject", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const decorator_liveblocks = paragraph_liveblocks + .get("children") + .get(0)! as LiveDecoratorNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + $setLexicalNodeProps(decorator, { + src: "https://example.com/b.png", + altText: "B", + }); + }, + { discrete: true } + ); + unregister(); + + expect(paragraph_liveblocks.get("children").get(0)).toBe( + decorator_liveblocks + ); + expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({ + src: "https://example.com/b.png", + altText: "B", + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + }); + }); + + test("preserves decorator LiveObject when only the right middle slot matches by type", () => { + // Force a middle window where the left slots differ (linebreak vs text) and + // the right slots are same-type decorators with different instances/props + // (so suffix identity / structural equality cannot claim them). + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const decorator_liveblocks = paragraph_liveblocks + .get("children") + .get(1)! as LiveDecoratorNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.clear(); + paragraph.append( + $createTextNode("Hi"), + $createCustomDecoratorNode({ + src: "https://example.com/b.png", + altText: "B", + }) + ); + }, + { discrete: true } + ); + unregister(); + + expect(paragraph_liveblocks.get("children").length).toBe(2); + expect(paragraph_liveblocks.get("children").get(0)!.get("kind")).toBe( + "text" + ); + expect( + (paragraph_liveblocks.get("children").get(0)! as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([["Hi"]]); + expect(paragraph_liveblocks.get("children").get(1)).toBe( + decorator_liveblocks + ); + expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({ + src: "https://example.com/b.png", + altText: "B", + }); + }); + + test("reconciles decorator ↔ linebreak swap to the expected storage order", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + + const unregister = editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + + editor.update( + () => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const decorator = paragraph.getChildAtIndex(0) as CustomDecoratorNode; + const linebreak = paragraph.getChildAtIndex(1)!; + decorator.remove(); + linebreak.insertAfter(decorator); + }, + { discrete: true } + ); + unregister(); + + expect(paragraph_liveblocks.get("children").length).toBe(2); + expect(paragraph_liveblocks.get("children").get(0)!.get("kind")).toBe( + "linebreak" + ); + expect(paragraph_liveblocks.get("children").get(1)!.get("kind")).toBe( + "decorator" + ); + expect( + (paragraph_liveblocks.get("children").get(1)! as LiveDecoratorNode) + .get("props") + ?.toJSON() + ).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + }); + + describe("$getLexicalNodeProps", () => { + test("returns undefined for a default paragraph", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + + expect($getLexicalNodeProps(paragraph)).toBeUndefined(); + }); + }); + + test("returns the heading tag for heading elements", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h3"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h3" }); + }); + }); + + test("includes the default h1 tag rather than omitting it", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h1" }); + }); + }); + + test("returns undefined for quote elements with no custom fields", () => { + const document = createParagraphDocument("Quoted"); + const { editor } = createEditor(document); + + editor.update(() => { + const quote = new QuoteNode(); + quote.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(quote); + }); + + editor.read(() => { + const quote = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(quote)).toBeUndefined(); + }); + }); + + test("omits layout fields that live outside storage props", () => { + const document = createParagraphDocument("Indented"); + const { editor } = createEditor(document); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.setIndent(2); + paragraph.setFormat("center"); + paragraph.setDirection("rtl"); + }); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + + expect(paragraph.getIndent()).toBe(2); + expect(paragraph.getFormatType()).toBe("center"); + expect(paragraph.getDirection()).toBe("rtl"); + expect($getLexicalNodeProps(paragraph)).toBeUndefined(); + }); + }); + + test("returns undefined for text nodes", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document); + + editor.read(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + expect($getLexicalNodeProps(text)).toBeUndefined(); + }); + }); + + test("returns custom fields for decorator nodes", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + }); + + test("includes default empty string fields rather than omitting them", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append($createCustomDecoratorNode()); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + // Empty strings are still exported — decorators with declared fields + // surface them the same way HeadingNode surfaces its default tag. + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "", + altText: "", + }); + }); + }); + }); + + describe("$setLexicalNodeProps", () => { + test("applies storage props onto a heading element", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, { tag: "h2" }); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect(heading.getType()).toBe("heading"); + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h2" }); + }); + }); + + test("preserves layout fields when only custom props change", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.setIndent(3); + heading.setFormat("right"); + heading.setDirection("ltr"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, { tag: "h4" }); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h4" }); + expect(heading.getIndent()).toBe(3); + expect(heading.getFormatType()).toBe("right"); + expect(heading.getDirection()).toBe("ltr"); + expect(heading.getTextContent()).toBe("Title"); + }); + }); + + test("resets custom props to type defaults when props is undefined", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, { tag: "h3" }); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, undefined); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h1" }); + }); + }); + + test("keeps layout fields when props is undefined on a paragraph", () => { + const document = createParagraphDocument("Indented"); + const { editor } = createEditor(document); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.setIndent(1); + paragraph.setFormat("center"); + }); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + $setLexicalNodeProps(paragraph, undefined); + }); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + + expect($getLexicalNodeProps(paragraph)).toBeUndefined(); + expect(paragraph.getIndent()).toBe(1); + expect(paragraph.getFormatType()).toBe("center"); + }); + }); + + test("does not clear undeclared custom props when given an empty props object", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, { tag: "h2" }); + }); + + editor.update(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + $setLexicalNodeProps(heading, {}); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h2" }); + }); + }); + + test("is a no-op for text nodes", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document); + + editor.update(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + $setLexicalNodeProps(text, { tag: "h1" }); + }); + + editor.read(() => { + const text = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + expect(text.getTextContent()).toBe("Hello"); + expect($getLexicalNodeProps(text)).toBeUndefined(); + }); + }); + + test("applies storage props onto a decorator node", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + editor.update(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + $setLexicalNodeProps(decorator, { + src: "https://example.com/b.png", + altText: "B", + }); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + expect(decorator.getSrc()).toBe("https://example.com/b.png"); + expect(decorator.getAltText()).toBe("B"); + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "https://example.com/b.png", + altText: "B", + }); + }); + }); + + test("resets decorator props to type defaults when props is undefined", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + editor.update(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + $setLexicalNodeProps(decorator, undefined); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + expect(decorator.getSrc()).toBe(""); + expect(decorator.getAltText()).toBe(""); + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "", + altText: "", + }); + }); + }); + + test("does not clear undeclared decorator props when given an empty props object", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + editor.update(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + $setLexicalNodeProps(decorator, {}); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + }); + }); + + describe("$getLexicalNodeProps / $setLexicalNodeProps round-trip", () => { + test("round-trips heading props through get and set", () => { + const document = createParagraphDocument("Title"); + const { editor } = createEditor(document); + + editor.update(() => { + const heading = $createHeadingNode("h2"); + heading.append( + ...($getRoot().getFirstChild() as ParagraphNode).getChildren() + ); + ($getRoot().getFirstChild() as ParagraphNode).replace(heading); + }); + + let props: ReturnType; + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + props = $getLexicalNodeProps(heading); + }); + + editor.update(() => { + const heading = $createHeadingNode("h1"); + heading.append($createTextNode("Other")); + $getRoot().clear(); + $getRoot().append(heading); + $setLexicalNodeProps(heading, props); + }); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h2" }); + expect(heading.getTextContent()).toBe("Other"); + }); + }); + + test("round-trips decorator props through get and set", () => { + const document = createParagraphDocument("Hello"); + const { editor } = createEditor(document, [CustomDecoratorNode]); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + let props: ReturnType; + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + props = $getLexicalNodeProps(decorator); + }); + + editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const decorator = $createCustomDecoratorNode({ + src: "https://example.com/other.png", + altText: "Other", + }); + paragraph.clear(); + paragraph.append(decorator); + $setLexicalNodeProps(decorator, props); + }); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + + expect($getLexicalNodeProps(decorator)).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + }); + + test("matches storage props used by element structural equality", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "heading", + version: 1, + props: new LiveMap([["tag", "h2"]]), + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Title"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor } = createEditor(document); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + const props_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ).get("props"); + + expect($getLexicalNodeProps(heading)).toEqual( + props_liveblocks?.toJSON() + ); + }); + }); + }); + + describe("$reconcileTextNodeFromLiveblocks", () => { + test("is a no-op on Lexical when content already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + + expect(text_lexical.getTextContent()).toBe("Hello world!"); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([ + text_lexical, + ]); + }); + }); + + test("updates Lexical text when LiveText content changes", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + text_liveblocks.get("content").replace(0, 5, "Hello!"); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getTextContent()).toBe("Hello!"); + }); + }); + + test("synchronizes bold formatting when plain text already matches", () => { + const document = createParagraphDocument("Hello world!"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + text_liveblocks.get("content").format(0, 12, { bold: true }); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getFormat()).toBe(1); + }); + }); + + test("removes Lexical TextNodes when LiveText is emptied", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + text_liveblocks.get("content").delete(0, 5); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + }); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(0); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([]); + }); + }); + + test("inserts TextNodes when LiveText gains content from an empty binding", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + text_liveblocks.get("content").delete(0, 5); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + }); + + text_liveblocks.get("content").insert(0, "Again"); + + editor.update(() => { + manager.$reconcileTextNodeFromLiveblocks([], text_liveblocks); + }); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(1); + expect(paragraph.getTextContent()).toBe("Again"); + expect(manager.binding.forward.get(text_liveblocks)).toHaveLength(1); + }); + }); + + test("materializes TextNode subclass from segment attribute type", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([["entity", { type: "custom-text" }]]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor } = createEditor(document, [CustomTextNode]); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("custom-text"); + expect(text_lexical.getTextContent()).toBe("entity"); + }); + }); + + test("materializes mixed plain and TextNode subclass siblings from LiveText", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["Hello "], + ["entity", { type: "custom-text" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor } = createEditor(document, [CustomTextNode]); + + editor.read(() => { + const textNodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) as TextNode[]; + expect(textNodes).toHaveLength(2); + expect(textNodes[0].getType()).toBe("text"); + expect(textNodes[0].getTextContent()).toBe("Hello "); + expect(textNodes[1].getType()).toBe("custom-text"); + expect(textNodes[1].getTextContent()).toBe("entity"); + }); + }); + + test("materializes TextNode subclass with inline format from LiveText", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["entity", { bold: true, type: "custom-text" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor } = createEditor(document, [CustomTextNode]); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("custom-text"); + expect(text_lexical.getTextContent()).toBe("entity"); + expect(text_lexical.getFormat()).toBe(1); + }); + }); + + test("materializes TextNode subclass exportJSON field from LiveText", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor } = createEditor(document, [CustomTextNode]); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text_lexical.getType()).toBe("custom-text"); + expect(text_lexical.getTextContent()).toBe("import"); + expect(text_lexical.getHighlightType()).toBe("keyword"); + }); + }); + + test("bootstrap skips node transforms", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + let transformRuns = 0; + const editor = createLexicalEditor({ + namespace: "bootstrap-skip-transforms", + nodes: [ + ParagraphNode, + TextNode, + HeadingNode, + QuoteNode, + CustomTextNode, + ], + }); + editor.registerNodeTransform(CustomTextNode, () => { + transformRuns++; + }); + + new LiveblocksCollaborationManager(document, editor); + + expect(transformRuns).toBe(0); + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + // Attributes still apply via updateFromJSON without transforms. + expect(text_lexical.getHighlightType()).toBe("keyword"); + }); + }); + + test("applies exportJSON field updates from LiveText onto existing TextNodes", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor, manager } = createEditor(document, [CustomTextNode]); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update( + () => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text_lexical.getHighlightType()).toBe("keyword"); + + text_liveblocks + .get("content") + .format(0, 6, { highlightType: "string" }); + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + + expect( + ( + $getNodeByKey(text_lexical.getKey()) as CustomTextNode + ).getHighlightType() + ).toBe("string"); + }, + { discrete: true, skipTransforms: true, tag: COLLABORATION_TAG } + ); + }); + + test("clears exportJSON field on existing TextNodes when removed from LiveText", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor, manager } = createEditor(document, [CustomTextNode]); + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + + editor.update( + () => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as CustomTextNode; + expect(text_lexical.getHighlightType()).toBe("keyword"); + + text_liveblocks.get("content").format(0, 6, { highlightType: null }); + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + + expect( + ( + $getNodeByKey(text_lexical.getKey()) as CustomTextNode + ).getHighlightType() + ).toBeUndefined(); + }, + { discrete: true, skipTransforms: true, tag: COLLABORATION_TAG } + ); + }); + + test("replaces Lexical text when segment type diverges", () => { + const document = createParagraphDocument("entity"); + const { editor, manager } = createEditor(document, [CustomTextNode]); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + text_liveblocks.get("content").format(0, 6, { type: "custom-text" }); + + editor.update(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + manager.$reconcileTextNodeFromLiveblocks( + [text_lexical], + text_liveblocks + ); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getType()).toBe("custom-text"); + expect(text_lexical.getTextContent()).toBe("entity"); + }); + }); + }); + + describe("$applyRemoteUpdates", () => { + test("ignores local storage updates", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const content = text_liveblocks.get("content"); + content.replace(0, 5, "Changed"); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: content, + version: content.version, + updates: [ + { + type: "delete", + index: 0, + length: 5, + deletedText: "Hello", + }, + { + type: "insert", + index: 0, + text: "Changed", + }, + ], + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getTextContent()).toBe("Hello"); + }); + }); + + test("applies local history (undo/redo) LiveText updates to Lexical", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const content = text_liveblocks.get("content"); + content.replace(0, 5, "Hello!"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: content, + version: content.version, + updates: [ + { + type: "delete", + index: 5, + length: 0, + deletedText: "", + }, + { + type: "insert", + index: 5, + text: "!", + }, + ], + source: { + origin: "local", + via: "redo", + }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: HISTORIC_TAG, + } + ); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getTextContent()).toBe("Hello!"); + }); + }); + + test("applies remote LiveText updates to Lexical", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const content = text_liveblocks.get("content"); + content.replace(0, 5, "Hello!"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: content, + version: content.version, + updates: [ + { + type: "delete", + index: 5, + length: 0, + deletedText: "", + }, + { + type: "insert", + index: 5, + text: "!", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const text_lexical = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(text_lexical.getTextContent()).toBe("Hello!"); + }); + }); + + test("ignores local LiveList inserts", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const paragraph_liveblocks = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Remote"), + }), + ]), + }) as LiveElementNode; + children_liveblocks.insert(paragraph_liveblocks, 1); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: paragraph_liveblocks, + }, + ], + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + }); + }); + + test("applies remote LiveList insert at root", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const paragraph_liveblocks = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Remote"), + }), + ]), + }) as LiveElementNode; + children_liveblocks.insert(paragraph_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: paragraph_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(2); + expect($getRoot().getChildAtIndex(1)?.getTextContent()).toBe("Remote"); + expect(manager.binding.forward.get(paragraph_liveblocks)).toBeDefined(); + }); + }); + + test("maps storage child index to lexical splice index for coalesced text", () => { + const textContent = new LiveText(); + textContent.insert(0, "Hello ", { bold: true }); + textContent.insert(6, "world"); + + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: textContent, + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(2); + }); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(3); + expect(paragraph_lexical.getTextContent()).toBe("Hello world\n"); + expect(paragraph_lexical.getChildAtIndex(2)?.getType()).toBe( + "linebreak" + ); + }); + }); + + test("ignores local LiveList deletes", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const deletedParagraph = children_liveblocks.get(1)!; + children_liveblocks.delete(1); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 1, + deletedItem: deletedParagraph, + }, + ], + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(2); + }); + }); + + test("applies remote LiveText clear to an empty paragraph", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const text_liveblocks = find_liveblocksNode( + document, + (node) => node.get("kind") === "text" + ) as LiveTextNode; + const content = text_liveblocks.get("content"); + content.delete(0, content.length); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: content, + version: content.version, + updates: [ + { + type: "delete", + index: 0, + length: 5, + deletedText: "Hello", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe(""); + expect( + ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize() + ).toBe(0); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([]); + }); + + // Empty binding is the canonical empty slot — remote insert materializes + // TextNodes into the paragraph. + content.insert(0, "Recovered"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: content, + version: content.version, + updates: [ + { + type: "insert", + index: 0, + text: "Recovered", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getFirstChild()?.getTextContent()).toBe("Recovered"); + }); + }); + + test("treats empty LiveText as zero Lexical span when inserting a sibling", () => { + const document = createParagraphDocument("Keep"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const empty_text = children_liveblocks.get(0) as LiveTextNode; + empty_text.get("content").delete(0, 4); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: empty_text.get("content"), + version: empty_text.get("content").version, + updates: [ + { + type: "delete", + index: 0, + length: 4, + deletedText: "Keep", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect( + ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize() + ).toBe(0); + expect(manager.binding.forward.get(empty_text)).toEqual([]); + }); + + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(1); + expect(paragraph.getChildAtIndex(0)?.getType()).toBe("linebreak"); + expect(manager.binding.forward.get(linebreak_liveblocks)).toBe( + paragraph.getChildAtIndex(0) + ); + }); + }); + + test("does not remove Lexical siblings when deleting an empty LiveText", () => { + const document = createParagraphDocument("Keep"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const empty_text = children_liveblocks.get(0) as LiveTextNode; + empty_text.get("content").delete(0, 4); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: empty_text.get("content"), + version: empty_text.get("content").version, + updates: [ + { + type: "delete", + index: 0, + length: 4, + deletedText: "Keep", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect( + ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize() + ).toBe(1); + }); + + children_liveblocks.delete(0); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 0, + deletedItem: empty_text, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(1); + expect(paragraph.getChildAtIndex(0)?.getType()).toBe("linebreak"); + expect(manager.binding.forward.get(empty_text)).toBeUndefined(); + expect(manager.binding.forward.get(linebreak_liveblocks)).toBeDefined(); + }); + }); + + test("inserts text after a preceding sibling when filling an empty LiveText", () => { + const document = createParagraphDocument("Keep"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const empty_text = children_liveblocks.get(0) as LiveTextNode; + empty_text.get("content").delete(0, 4); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: empty_text.get("content"), + version: empty_text.get("content").version, + updates: [ + { + type: "delete", + index: 0, + length: 4, + deletedText: "Keep", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 0); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 0, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(1); + expect(paragraph.getChildAtIndex(0)?.getType()).toBe("linebreak"); + expect(manager.binding.forward.get(empty_text)).toEqual([]); + }); + + empty_text.get("content").insert(0, "After"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: empty_text.get("content"), + version: empty_text.get("content").version, + updates: [ + { + type: "insert", + index: 0, + text: "After", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + expect(paragraph.getChildAtIndex(0)?.getType()).toBe("linebreak"); + expect(paragraph.getChildAtIndex(1)?.getTextContent()).toBe("After"); + expect(manager.binding.forward.get(empty_text)).toHaveLength(1); + }); + }); + + test("preserves order when applying remote multi-paragraph insert", () => { + const document = createParagraphDocument("P1"); + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const p2 = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("P2"), + }), + ]), + }) as LiveElementNode; + const p3 = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("P3"), + }), + ]), + }) as LiveElementNode; + children_liveblocks.insert(p2, 1); + children_liveblocks.insert(p3, 2); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { type: "insert", index: 1, item: p2 }, + { type: "insert", index: 2, item: p3 }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(3); + expect( + $getRoot() + .getChildren() + .map((c) => c.getTextContent()) + ).toEqual(["P1", "P2", "P3"]); + }); + }); + + test("applies remote delete-all to a peer editor", () => { + // Shared storage starts with three paragraphs. Client A clears the doc; + // client B must apply the resulting remote LiveList deltas. + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Alpha"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Beta"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Gamma"), + }), + ]), + }), + ]), + }) as LiveRootNode; + + const peer = createEditor(document); + const local = createEditor(document); + + peer.editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(3); + expect( + $getRoot() + .getChildren() + .map((c) => c.getTextContent()) + ).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + const children_liveblocks = document.get("children"); + const before = [ + children_liveblocks.get(0)!, + children_liveblocks.get(1)!, + children_liveblocks.get(2)!, + ]; + + const syncLocal = (fn: () => void) => { + const unregister = local.editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + local.manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + local.editor.update(fn, { discrete: true }); + unregister(); + }; + + syncLocal(() => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }); + + // Storage should reflect the empty document. + const afterChildren = document.get("children"); + expect(afterChildren.length).toBe(1); + const afterParagraph = afterChildren.get(0) as LiveElementNode; + expect((afterParagraph as LiveElementNode).get("children").length).toBe( + 1 + ); + expect( + ( + (afterParagraph as LiveElementNode) + .get("children") + .get(0)! as LiveTextNode + ) + .get("content") + .toJSON() + ).toEqual([]); + // Helpful when diagnosing reuse vs replace of the first paragraph. + expect(before.includes(afterParagraph)).toBeTypeOf("boolean"); + + // Peer still has the old Lexical tree until remote updates are applied. + peer.editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(3); + }); + + // Reconstruct the LiveList deltas a subscriber would see for delete-all + // followed by inserting the empty paragraph. LiveList.clear emits + // delete@0 for each item; then insert@0 for the replacement. + const remaining = document.get("children").get(0)!; + const wasReused = before.includes(remaining); + const text_liveblocks = (remaining as LiveElementNode) + .get("children") + .get(0) as LiveTextNode; + + peer.editor.update( + () => { + if (wasReused) { + peer.manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { type: "delete", index: 1, deletedItem: before[1]! }, + { type: "delete", index: 1, deletedItem: before[2]! }, + ], + source: { origin: "remote" }, + }, + { + type: "LiveText", + node: text_liveblocks.get("content"), + version: text_liveblocks.get("content").version, + updates: [ + { + type: "delete", + index: 0, + length: 5, + deletedText: "Alpha", + }, + ], + source: { origin: "remote" }, + }, + ]); + } else { + peer.manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { type: "delete", index: 0, deletedItem: before[0]! }, + { type: "delete", index: 0, deletedItem: before[1]! }, + { type: "delete", index: 0, deletedItem: before[2]! }, + { type: "insert", index: 0, item: remaining }, + ], + source: { origin: "remote" }, + }, + ]); + } + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + peer.editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe(""); + }); + }); + + test("applies remote LiveText fill after remote delete-all", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Alpha"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Beta"), + }), + ]), + }), + ]), + }) as LiveRootNode; + + const peer = createEditor(document); + const local = createEditor(document); + + const children_liveblocks = document.get("children"); + const before = [children_liveblocks.get(0)!, children_liveblocks.get(1)!]; + + const syncLocal = (fn: () => void) => { + const unregister = local.editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + local.manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + local.editor.update(fn, { discrete: true }); + unregister(); + }; + + syncLocal(() => { + const root = $getRoot(); + root.clear(); + root.append($createParagraphNode()); + }); + + const remaining = document.get("children").get(0)!; + const text_liveblocks = (remaining as LiveElementNode) + .get("children") + .get(0) as LiveTextNode; + + peer.editor.update( + () => { + peer.manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [{ type: "delete", index: 1, deletedItem: before[1]! }], + source: { origin: "remote" }, + }, + { + type: "LiveText", + node: text_liveblocks.get("content"), + version: text_liveblocks.get("content").version, + updates: [ + { + type: "delete", + index: 0, + length: 5, + deletedText: "Alpha", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { discrete: true, skipTransforms: true, tag: COLLABORATION_TAG } + ); + + peer.editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe(""); + }); + + // Local types into the empty paragraph + syncLocal(() => { + ($getRoot().getFirstChild() as ParagraphNode).append( + $createTextNode("KeepMe") + ); + }); + + expect(text_liveblocks.get("content").toJSON()).toEqual([["KeepMe"]]); + + peer.editor.update( + () => { + peer.manager.$applyRemoteUpdates([ + { + type: "LiveText", + node: text_liveblocks.get("content"), + version: text_liveblocks.get("content").version, + updates: [ + { + type: "insert", + index: 0, + text: "KeepMe", + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { discrete: true, skipTransforms: true, tag: COLLABORATION_TAG } + ); + + peer.editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe("KeepMe"); + }); + }); + + test("applies remote LiveList delete at root", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const deletedParagraph = children_liveblocks.get(1)!; + children_liveblocks.delete(1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 1, + deletedItem: deletedParagraph, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe("One"); + expect( + manager.binding.forward.get(deletedParagraph as LiveStorageNode) + ).toBeUndefined(); + }); + }); + + test("maps storage child index when deleting coalesced text", () => { + const textContent = new LiveText(); + textContent.insert(0, "Hello ", { bold: true }); + textContent.insert(6, "world"); + + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: textContent, + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(3); + }); + + children_liveblocks.delete(1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 1, + deletedItem: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(2); + expect(paragraph_lexical.getTextContent()).toBe("Hello world"); + }); + }); + + test("ignores local LiveList moves", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const movedParagraph = children_liveblocks.get(0)!; + children_liveblocks.move(0, 1); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "move", + previousIndex: 0, + index: 1, + item: movedParagraph, + }, + ], + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + expect($getRoot().getChildAtIndex(0)?.getTextContent()).toBe("One"); + expect($getRoot().getChildAtIndex(1)?.getTextContent()).toBe("Two"); + }); + }); + + test("applies remote LiveList move at root", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Three"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const movedParagraph = children_liveblocks.get(0)!; + children_liveblocks.move(0, 2); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "move", + previousIndex: 0, + index: 2, + item: movedParagraph, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(3); + expect($getRoot().getChildAtIndex(0)?.getTextContent()).toBe("Two"); + expect($getRoot().getChildAtIndex(1)?.getTextContent()).toBe("Three"); + expect($getRoot().getChildAtIndex(2)?.getTextContent()).toBe("One"); + expect(manager.binding.forward.get(movedParagraph)).toBe( + $getRoot().getChildAtIndex(2) + ); + }); + }); + + test("maps storage child index when moving coalesced text", () => { + const textContent = new LiveText(); + textContent.insert(0, "Hello ", { bold: true }); + textContent.insert(6, "world"); + + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: textContent, + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(3); + expect(paragraph_lexical.getChildAtIndex(2)?.getType()).toBe( + "linebreak" + ); + }); + + children_liveblocks.move(1, 0); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "move", + previousIndex: 1, + index: 0, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(3); + expect(paragraph_lexical.getChildAtIndex(0)?.getType()).toBe( + "linebreak" + ); + expect(paragraph_lexical.getTextContent()).toBe("\nHello world"); + }); + }); + + test("ignores local LiveList sets", () => { + const document = createParagraphDocument("Hello"); + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const oldParagraph = children_liveblocks.get(0)!; + const newParagraph = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Replaced"), + }), + ]), + }) as LiveElementNode; + children_liveblocks.set(0, newParagraph); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "set", + index: 0, + item: newParagraph, + }, + ], + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getFirstChild()?.getTextContent()).toBe("Hello"); + expect(manager.binding.forward.get(oldParagraph)).toBeDefined(); + }); + }); + + test("applies remote LiveList set at root", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("One"), + }), + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Two"), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const children_liveblocks = document.get("children"); + const oldParagraph = children_liveblocks.get(1)!; + const newParagraph = new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Replaced"), + }), + ]), + }) as LiveElementNode; + children_liveblocks.set(1, newParagraph); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "set", + index: 1, + item: newParagraph, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(2); + expect($getRoot().getChildAtIndex(0)?.getTextContent()).toBe("One"); + expect($getRoot().getChildAtIndex(1)?.getTextContent()).toBe( + "Replaced" + ); + expect( + manager.binding.forward.get(oldParagraph as LiveStorageNode) + ).toBeUndefined(); + expect(manager.binding.forward.get(newParagraph)).toBe( + $getRoot().getChildAtIndex(1) + ); + }); + }); + + test("maps storage child index when setting over coalesced text", () => { + const textContent = new LiveText(); + textContent.insert(0, "Hello ", { bold: true }); + textContent.insert(6, "world"); + + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: textContent, + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const linebreak_liveblocks = new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }) as LiveLineBreakNode; + children_liveblocks.insert(linebreak_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: linebreak_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(3); + }); + + const oldText = children_liveblocks.get(0)!; + const replacement = new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }) as LiveTextNode; + children_liveblocks.set(0, replacement); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "set", + index: 0, + item: replacement, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph_lexical = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph_lexical.getChildrenSize()).toBe(2); + expect(paragraph_lexical.getChildAtIndex(0)?.getTextContent()).toBe( + "Hi" + ); + expect(paragraph_lexical.getChildAtIndex(1)?.getType()).toBe( + "linebreak" + ); + expect( + manager.binding.forward.get(oldText as LiveStorageNode) + ).toBeUndefined(); + expect(manager.binding.forward.get(replacement)).toEqual([ + paragraph_lexical.getChildAtIndex(0), + ]); + }); + }); + + test("ignores local LiveObject prop updates", () => { + const document = createParagraphDocument("Title"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + paragraph_liveblocks.set("type", "heading"); + paragraph_liveblocks.set("props", new LiveMap([["tag", "h2"]])); + + editor.update(() => { + manager.$applyRemoteUpdates([ + { + type: "LiveObject", + node: paragraph_liveblocks, + updates: { + type: { type: "update" }, + props: { type: "update" }, + }, + source: { origin: "local", via: "edit" }, + }, + ]); + }); + + editor.read(() => { + expect($getRoot().getFirstChild()?.getType()).toBe("paragraph"); + }); + }); + + test("applies remote LiveObject type and props updates", () => { + const document = createParagraphDocument("Title"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + paragraph_liveblocks.set("type", "heading"); + paragraph_liveblocks.set("props", new LiveMap([["tag", "h2"]])); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveObject", + node: paragraph_liveblocks, + updates: { + type: { type: "update" }, + props: { type: "update" }, + }, + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + expect(heading.getType()).toBe("heading"); + expect(heading.getTextContent()).toBe("Title"); + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h2" }); + expect(manager.binding.forward.get(paragraph_liveblocks)).toBe(heading); + }); + }); + + test("applies remote LiveMap prop updates on an existing props map", () => { + const document = createParagraphDocument("Title"); + const { editor, manager } = createEditor(document); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + paragraph_liveblocks.set("type", "heading"); + const props = new LiveMap([["tag", "h1"]]); + paragraph_liveblocks.set("props", props); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveObject", + node: paragraph_liveblocks, + updates: { + type: { type: "update" }, + props: { type: "update" }, + }, + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + props.set("tag", "h3"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveMap", + node: props, + updates: { + tag: { type: "update" }, + }, + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const heading = $getRoot().getFirstChild() as ElementNode; + expect(heading.getType()).toBe("heading"); + expect($getLexicalNodeProps(heading)).toEqual({ tag: "h3" }); + }); + }); + + test("applies remote LiveList insert of a decorator child", () => { + const document = createParagraphDocument("Hi"); + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const decorator_liveblocks = new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }) as LiveDecoratorNode; + children_liveblocks.insert(decorator_liveblocks, 1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: decorator_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + const decorator = paragraph.getChildAtIndex(1) as CustomDecoratorNode; + expect($isCustomDecoratorNode(decorator)).toBe(true); + expect(decorator.getSrc()).toBe("https://example.com/a.png"); + expect(decorator.getAltText()).toBe("A"); + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + expect(manager.binding.reverse.get(decorator.getKey())).toBe( + decorator_liveblocks + ); + }); + }); + + test("applies remote LiveList delete of a decorator child", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const decorator_liveblocks = children_liveblocks.get(1)!; + + children_liveblocks.delete(1); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 1, + deletedItem: decorator_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(1); + expect(paragraph.getFirstChild()?.getTextContent()).toBe("Hi"); + }); + }); + + test("ignores remote LiveList delete when Lexical parent is already empty", () => { + // Concurrent local delete (or decorator-only paragraph cleared) can leave + // Lexical empty while a remote/history delete for the same storage child + // still arrives — must not splice past oldSize. + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const children_liveblocks = paragraph_liveblocks.get("children"); + const decorator_liveblocks = children_liveblocks.get(0)!; + + editor.update( + () => { + ($getRoot().getFirstChild() as ParagraphNode).clear(); + }, + { discrete: true, tag: COLLABORATION_TAG } + ); + + children_liveblocks.delete(0); + + expect(() => { + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "delete", + index: 0, + deletedItem: decorator_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + }).not.toThrow(); + + editor.read(() => { + expect( + ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize() + ).toBe(0); + }); + }); + + test("applies remote LiveObject props updates on a decorator", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const decorator_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveDecoratorNode; + + decorator_liveblocks.set( + "props", + new LiveMap([ + ["src", "https://example.com/b.png"], + ["altText", "B"], + ]) + ); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveObject", + node: decorator_liveblocks, + updates: { + props: { type: "update" }, + }, + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + expect(decorator.getSrc()).toBe("https://example.com/b.png"); + expect(decorator.getAltText()).toBe("B"); + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + }); + }); + + test("applies remote LiveMap prop updates on a decorator", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const decorator_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveDecoratorNode; + const props = decorator_liveblocks.get("props")!; + + props.set("src", "https://example.com/c.png"); + props.set("altText", "C"); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveMap", + node: props, + updates: { + src: { type: "update" }, + altText: { type: "update" }, + }, + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const decorator = $dfs().find(({ node }) => + $isCustomDecoratorNode(node) + )!.node as CustomDecoratorNode; + expect(decorator.getSrc()).toBe("https://example.com/c.png"); + expect(decorator.getAltText()).toBe("C"); + }); + }); + + test("applies remote LiveList move of a decorator child", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const children_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ).get("children"); + const decorator_liveblocks = children_liveblocks.get(1)!; + + children_liveblocks.move(1, 0); + + editor.update( + () => { + manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "move", + previousIndex: 1, + index: 0, + item: decorator_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + expect($isCustomDecoratorNode(paragraph.getChildAtIndex(0))).toBe(true); + expect(paragraph.getChildAtIndex(1)?.getTextContent()).toBe("Hi"); + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + paragraph.getChildAtIndex(0) + ); + }); + }); + + test("applies a peer decorator insert to a second editor", () => { + const document = createParagraphDocument("Hi"); + const local = createEditor(document, [CustomDecoratorNode]); + const peer = createEditor(document, [CustomDecoratorNode]); + + const children_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ).get("children"); + + const syncLocal = (fn: () => void) => { + const unregister = local.editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + local.manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + local.editor.update(fn, { discrete: true }); + unregister(); + }; + + syncLocal(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraph.append( + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + }); + + expect(children_liveblocks.length).toBe(2); + const decorator_liveblocks = children_liveblocks.get(1)!; + expect(decorator_liveblocks.get("kind")).toBe("decorator"); + + peer.editor.update( + () => { + peer.manager.$applyRemoteUpdates([ + { + type: "LiveList", + node: children_liveblocks, + updates: [ + { + type: "insert", + index: 1, + item: decorator_liveblocks, + }, + ], + source: { origin: "remote" }, + }, + ]); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + peer.editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + const decorator = paragraph.getChildAtIndex(1) as CustomDecoratorNode; + expect($isCustomDecoratorNode(decorator)).toBe(true); + expect(decorator.getSrc()).toBe("https://example.com/a.png"); + expect(peer.manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + }); + }); + + /** + * Typing into the middle of a segmented TextNode subclass (classic Lexical + * mention pattern). Lexical demotes the subclass to a plain TextNode + * locally; LiveText must clear leftover type/mode on the surrounding + * segments so peers rebuild the same plain text tree. + */ + test("insert into segmented mention converges local and peer on plain text", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + createLiveTextNode([ + ["@alice", { type: "custom-text", mode: "segmented" }], + ]), + ]), + }), + ]), + }) as LiveRootNode; + + const local = createEditor(document, [CustomTextNode]); + const peer = createEditor(document, [CustomTextNode]); + + const text_liveblocks = ( + document.get("children").get(0) as LiveElementNode + ) + .get("children") + .get(0)! as LiveTextNode; + + local.editor.read(() => { + const nodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) as TextNode[]; + expect(nodes).toHaveLength(1); + expect(nodes[0]!.getType()).toBe("custom-text"); + expect(nodes[0]!.getMode()).toBe("segmented"); + expect(nodes[0]!.getTextContent()).toBe("@alice"); + }); + peer.editor.read(() => { + const nodes = $dfs() + .map(({ node }) => node) + .filter($isTextNode) as TextNode[]; + expect(nodes).toHaveLength(1); + expect(nodes[0]!.getType()).toBe("custom-text"); + }); + + const syncLocal = (fn: () => void) => { + const unregister = local.editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, normalizedNodes, editorState }) => { + editorState.read(() => { + local.manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + } + ); + local.editor.update(fn, { discrete: true }); + unregister(); + }; + + // User A: type 'n' after 'i' in "@alice" → "@alince" + syncLocal(() => { + const mention = $dfs().find(({ node }) => $isTextNode(node))! + .node as TextNode; + expect(mention.getType()).toBe("custom-text"); + expect(mention.isSegmented()).toBe(true); + const selection = $createRangeSelection(); + selection.anchor.set(mention.getKey(), 4, "text"); + selection.focus.set(mention.getKey(), 4, "text"); + $setSelection(selection); + selection.insertText("n"); + }); + + // Format pass clears type/mode left behind by the string-diff insert. + expect(text_liveblocks.get("content").toJSON()).toEqual([["@alince"]]); + + const readTextShape = (editor: LexicalEditor) => + editor.read(() => + $dfs() + .map(({ node }) => node) + .filter($isTextNode) + .map((node) => ({ + type: node.getType(), + text: node.getTextContent(), + mode: node.getMode(), + })) + ); + + const localShape = readTextShape(local.editor); + expect(localShape.every((n) => n.type === "text")).toBe(true); + expect(localShape.map((n) => n.text).join("")).toBe("@alince"); + + peer.editor.update( + () => { + const bound = peer.manager.binding.forward.get(text_liveblocks); + expect(Array.isArray(bound)).toBe(true); + peer.manager.$reconcileTextNodeFromLiveblocks( + bound as TextNode[], + text_liveblocks + ); + }, + { + discrete: true, + skipTransforms: true, + tag: COLLABORATION_TAG, + } + ); + + const peerShape = readTextShape(peer.editor); + expect(peerShape.every((n) => n.type === "text")).toBe(true); + expect(peerShape.map((n) => n.text).join("")).toBe("@alince"); + expect(localShape).toEqual(peerShape); + }); + }); + + describe("areTextNodesStructurallyEqual", () => { + const editor = createLexicalEditor({ + namespace: "areTextNodesStructurallyEqual", + nodes: [ParagraphNode, TextNode], + }); + + test("returns true when a single LiveText segment matches one TextNode", () => { + const text_liveblocks = createLiveTextNode([["Hello world!"]]); + const text_lexical = createTextNodes(editor, ["Hello world!"]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(true); + }); + + test("returns true for empty LiveText and an empty Lexical text slot", () => { + expect(areTextNodesStructurallyEqual(createLiveTextNode([]), [])).toBe( + true + ); + }); + + test("returns false for empty LiveText and a placeholder TextNode", () => { + const placeholder = { + getLatest: () => placeholder, + getTextContent: () => "", + getType: () => "text", + getFormat: () => 0, + getMode: () => "normal" as const, + getDetail: () => 0, + getStyle: () => "", + } as TextNode; + + expect( + areTextNodesStructurallyEqual(createLiveTextNode([]), [placeholder]) + ).toBe(false); + }); + + test("returns true for multiple matching segments and sibling TextNodes", () => { + const text_liveblocks = createLiveTextNode([ + ["Hello ", { bold: true }], + ["world"], + ]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello ", bold: true }, + "world", + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(true); + }); + + test("returns true when mode and style match on a segment", () => { + const text_liveblocks = createLiveTextNode([ + ["Hello", { mode: "token", style: "color: red" }], + ]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello", mode: "token", style: "color: red" }, + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(true); + }); + + test("returns false when plain text differs", () => { + const text_liveblocks = createLiveTextNode([["Hello world!"]]); + const text_lexical = createTextNodes(editor, ["Hello there!"]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when inline format differs", () => { + const text_liveblocks = createLiveTextNode([["Hello world!"]]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello world!", bold: true }, + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when segment count differs", () => { + const text_liveblocks = createLiveTextNode([["Hello world"]]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello ", bold: true }, + "world", + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when mode differs", () => { + const text_liveblocks = createLiveTextNode([["Hello"]]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello", mode: "token" }, + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when style differs", () => { + const text_liveblocks = createLiveTextNode([["Hello"]]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello", style: "color: red" }, + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when LiveText is empty but Lexical has multiple TextNodes", () => { + const text_liveblocks = createLiveTextNode([]); + const text_lexical = createTextNodes(editor, [ + { text: "Hello ", bold: true }, + "world", + ]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns false when empty LiveText is compared with a non-empty TextNode", () => { + const text_liveblocks = createLiveTextNode([]); + const text_lexical = createTextNodes(editor, ["Hello"]); + + expect( + editor.read(() => + areTextNodesStructurallyEqual(text_liveblocks, text_lexical) + ) + ).toBe(false); + }); + + test("returns true when TextNode subclass type matches segment attribute type", () => { + const customEditor = createLexicalEditor({ + namespace: "areTextNodesStructurallyEqual-custom", + nodes: [ParagraphNode, TextNode, CustomTextNode], + }); + let key = ""; + customEditor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomTextNode("entity"); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + expect( + customEditor.read(() => + areTextNodesStructurallyEqual( + createLiveTextNode([["entity", { type: "custom-text" }]]), + [$getNodeByKey(key) as TextNode] + ) + ) + ).toBe(true); + }); + + test("returns true when exportJSON field matches segment attribute", () => { + const customEditor = createLexicalEditor({ + namespace: "areTextNodesStructurallyEqual-highlightType", + nodes: [ParagraphNode, TextNode, CustomTextNode], + }); + let key = ""; + customEditor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomTextNode("import", "keyword"); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + expect( + customEditor.read(() => + areTextNodesStructurallyEqual( + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + [$getNodeByKey(key) as TextNode] + ) + ) + ).toBe(true); + }); + + test("returns false when exportJSON field differs from segment attribute", () => { + const customEditor = createLexicalEditor({ + namespace: "areTextNodesStructurallyEqual-highlightType-mismatch", + nodes: [ParagraphNode, TextNode, CustomTextNode], + }); + let key = ""; + customEditor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomTextNode("import", "string"); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + expect( + customEditor.read(() => + areTextNodesStructurallyEqual( + createLiveTextNode([ + ["import", { type: "custom-text", highlightType: "keyword" }], + ]), + [$getNodeByKey(key) as TextNode] + ) + ) + ).toBe(false); + }); + + test("returns false when TextNode subclass type differs from segment attribute type", () => { + const customEditor = createLexicalEditor({ + namespace: "areTextNodesStructurallyEqual-custom-mismatch", + nodes: [ParagraphNode, TextNode, CustomTextNode], + }); + let key = ""; + customEditor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomTextNode("entity"); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + expect( + customEditor.read(() => + areTextNodesStructurallyEqual(createLiveTextNode([["entity"]]), [ + $getNodeByKey(key) as TextNode, + ]) + ) + ).toBe(false); + }); + }); + + describe("createStorageNodeFromLexicalNode", () => { + test("stores TextNode subclass type on LiveText segments", () => { + const editor = createLexicalEditor({ + namespace: "createStorageNodeFromLexicalNode-custom", + nodes: [ParagraphNode, TextNode, CustomTextNode], + }); + let key = ""; + editor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomTextNode("entity"); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + expect( + editor.read(() => + createStorageNodeFromLexicalNode([$getNodeByKey(key) as TextNode]) + .get("content") + .toJSON() + ) + ).toEqual([["entity", { type: "custom-text" }]]); + }); + + test("materializes a decorator node with props", () => { + const editor = createLexicalEditor({ + namespace: "createStorageNodeFromLexicalNode-decorator", + nodes: [ParagraphNode, TextNode, CustomDecoratorNode], + }); + let key = ""; + editor.update(() => { + const paragraph = $createParagraphNode(); + const node = $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }); + paragraph.append(node); + $getRoot().clear(); + $getRoot().append(paragraph); + key = node.getKey(); + }); + + const storage = editor.read(() => + createStorageNodeFromLexicalNode( + $getNodeByKey(key) as CustomDecoratorNode + ) + ); + + expect(storage.get("kind")).toBe("decorator"); + expect(storage.get("type")).toBe("custom-decorator"); + expect(storage.get("props")?.toJSON()).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + + test("materializes decorator children inside an element", () => { + const editor = createLexicalEditor({ + namespace: "createStorageNodeFromLexicalNode-decorator-child", + nodes: [ParagraphNode, TextNode, CustomDecoratorNode], + }); + let key = ""; + editor.update(() => { + const paragraph = $createParagraphNode(); + paragraph.append( + $createTextNode("Hi"), + $createCustomDecoratorNode({ + src: "https://example.com/a.png", + altText: "A", + }) + ); + $getRoot().clear(); + $getRoot().append(paragraph); + key = paragraph.getKey(); + }); + + const storage = editor.read(() => + createStorageNodeFromLexicalNode($getNodeByKey(key) as ParagraphNode) + ); + + expect(storage.get("kind")).toBe("element"); + expect(storage.get("children").length).toBe(2); + expect(storage.get("children").get(0)!.get("kind")).toBe("text"); + expect( + (storage.get("children").get(0)! as LiveTextNode) + .get("content") + .toJSON() + ).toEqual([["Hi"]]); + + const decorator = storage.get("children").get(1)! as LiveDecoratorNode; + expect(decorator.get("kind")).toBe("decorator"); + expect(decorator.get("type")).toBe("custom-decorator"); + expect(decorator.get("props")?.toJSON()).toEqual({ + src: "https://example.com/a.png", + altText: "A", + }); + }); + }); + + describe("decorator bootstrap and binding", () => { + test("bootstraps a decorator child from storage and binds it", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Hi"), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const paragraph_liveblocks = document + .get("children") + .get(0) as LiveElementNode; + const text_liveblocks = paragraph_liveblocks + .get("children") + .get(0)! as LiveTextNode; + const decorator_liveblocks = paragraph_liveblocks.get("children").get(1)!; + + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + expect(paragraph.getChildrenSize()).toBe(2); + + const text = paragraph.getChildAtIndex(0) as TextNode; + expect($isTextNode(text)).toBe(true); + expect(text.getTextContent()).toBe("Hi"); + + const decorator = paragraph.getChildAtIndex(1) as CustomDecoratorNode; + expect($isCustomDecoratorNode(decorator)).toBe(true); + expect(decorator.getSrc()).toBe("https://example.com/a.png"); + expect(decorator.getAltText()).toBe("A"); + + expect(manager.binding.forward.get(paragraph_liveblocks)).toBe( + paragraph + ); + expect(manager.binding.forward.get(text_liveblocks)).toEqual([text]); + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + expect(manager.binding.reverse.get(decorator.getKey())).toBe( + decorator_liveblocks + ); + }); + }); + + test("bootstraps a root-level decorator sibling (e.g. horizontal rule)", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Above"), + }), + ]), + }), + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/hr.png"], + ["altText", "rule"], + ]), + }), + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText("Below"), + }), + ]), + }), + ]), + }) as LiveRootNode; + + const { editor, manager } = createEditor(document, [CustomDecoratorNode]); + const decorator_liveblocks = document.get("children").get(1)!; + + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(3); + expect($getRoot().getChildAtIndex(0)?.getType()).toBe("paragraph"); + const decorator = $getRoot().getChildAtIndex(1) as CustomDecoratorNode; + expect($isCustomDecoratorNode(decorator)).toBe(true); + expect(decorator.getSrc()).toBe("https://example.com/hr.png"); + expect($getRoot().getChildAtIndex(2)?.getType()).toBe("paragraph"); + expect(manager.binding.forward.get(decorator_liveblocks)).toBe( + decorator + ); + expect(manager.binding.reverse.get(decorator.getKey())).toBe( + decorator_liveblocks + ); + }); + }); + + test("reports an error when bootstrapping an unregistered decorator type", () => { + const document = new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "decorator", + type: "custom-decorator", + version: 1, + props: new LiveMap([ + ["src", "https://example.com/a.png"], + ["altText", "A"], + ]), + }), + ]), + }), + ]), + }) as LiveRootNode; + + const onError = vi.fn(); + const editor = createLexicalEditor({ + namespace: "decorator-bootstrap-unregistered", + nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode], + onError, + }); + new LiveblocksCollaborationManager(document, editor); + + expect(onError).toHaveBeenCalled(); + expect((onError.mock.calls[0]![0] as Error).message).toMatch( + /Node of type "custom-decorator" is not registered/ + ); + }); + }); +}); + +/** + * Minimal TextNode subclass used to cover LiveText `attributes.type` round-trips + * for any registered text entity type (not only the built-in `"text"` node), + * plus public exportJSON fields beyond marks (mirrors CodeHighlightNode's + * `highlightType`). + */ +type SerializedCustomTextNode = Spread< + { + highlightType?: string | null; + }, + SerializedTextNode +>; + +class CustomTextNode extends TextNode { + __highlightType: string | null | undefined; + + static getType(): string { + return "custom-text"; + } + + static clone(node: CustomTextNode): CustomTextNode { + return new CustomTextNode( + node.__text, + node.__highlightType || undefined, + node.__key + ); + } + + constructor(text: string, highlightType?: string | null, key?: NodeKey) { + super(text, key); + this.__highlightType = highlightType; + } + + getHighlightType(): string | null | undefined { + return this.getLatest().__highlightType; + } + + setHighlightType(highlightType?: string | null): this { + const self = this.getWritable(); + self.__highlightType = highlightType || undefined; + return self; + } + + createDOM(config: EditorConfig): HTMLElement { + return super.createDOM(config); + } + + static importJSON(serializedNode: SerializedCustomTextNode): CustomTextNode { + return $createCustomTextNode().updateFromJSON(serializedNode); + } + + updateFromJSON( + serializedNode: LexicalUpdateJSON + ): this { + return super + .updateFromJSON(serializedNode) + .setHighlightType(serializedNode.highlightType); + } + + exportJSON(): SerializedCustomTextNode { + return { + ...super.exportJSON(), + highlightType: this.getHighlightType(), + }; + } +} + +function $createCustomTextNode( + text = "", + highlightType?: string | null +): CustomTextNode { + return $applyNodeReplacement(new CustomTextNode(text, highlightType)); +} + +type SerializedCustomDecoratorNode = Spread< + { + src: string; + altText: string; + }, + SerializedLexicalNode +>; + +/** + * Minimal DecoratorNode used to cover storage `props` round-trips for + * decorator types (e.g. images) via `exportJSON` / `updateFromJSON`. + * + * Defaults are empty strings so `new CustomDecoratorNode()` works for the + * fresh-instance path inside `$setLexicalNodeProps`. + */ +class CustomDecoratorNode extends DecoratorNode { + __src: string; + __altText: string; + + static getType(): string { + return "custom-decorator"; + } + + static clone(node: CustomDecoratorNode): CustomDecoratorNode { + return new CustomDecoratorNode(node.__src, node.__altText, node.__key); + } + + static importJSON( + serializedNode: SerializedCustomDecoratorNode + ): CustomDecoratorNode { + return $createCustomDecoratorNode().updateFromJSON(serializedNode); + } + + constructor(src = "", altText = "", key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText; + } + + exportJSON(): SerializedCustomDecoratorNode { + return { + ...super.exportJSON(), + src: this.__src, + altText: this.__altText, + }; + } + + updateFromJSON( + serializedNode: LexicalUpdateJSON + ): this { + const node = super.updateFromJSON(serializedNode); + const writable = node.getWritable(); + if (serializedNode.src !== undefined) { + writable.__src = serializedNode.src; + } + if (serializedNode.altText !== undefined) { + writable.__altText = serializedNode.altText; + } + return writable; + } + + createDOM(_config: EditorConfig): HTMLElement { + return document.createElement("span"); + } + + updateDOM(): false { + return false; + } + + decorate(): null { + return null; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } +} + +function $createCustomDecoratorNode({ + src = "", + altText = "", +}: { + src?: string; + altText?: string; +} = {}): CustomDecoratorNode { + return $applyNodeReplacement(new CustomDecoratorNode(src, altText)); +} + +function $isCustomDecoratorNode( + node: LexicalNode | null | undefined +): node is CustomDecoratorNode { + return node instanceof CustomDecoratorNode; +} + +function createParagraphDocument(text: string): LiveRootNode { + return new LiveObject({ + kind: "root", + type: "root", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "element", + type: "paragraph", + version: 1, + children: new LiveList([ + new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(text), + }), + ]), + }), + ]), + }) as LiveRootNode; +} + +function createEditor( + document: LiveRootNode, + extraNodes: Array = [] +): { + editor: LexicalEditor; + manager: LiveblocksCollaborationManager; +} { + const editor = createLexicalEditor({ + namespace: "test", + nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode, ...extraNodes], + }); + + const manager = new LiveblocksCollaborationManager(document, editor); + + return { editor, manager }; +} + +function createLiveTextNode( + segments: Array<[string] | [string, TextAttributes]> +): LiveTextNode { + const content = new LiveText(); + let offset = 0; + for (const segment of segments) { + const [text, attributes] = segment; + if (text.length === 0) { + continue; + } + content.insert( + offset, + text, + attributes !== undefined ? attributes : undefined + ); + offset += text.length; + } + + return new LiveObject({ + kind: "text", + type: "text", + version: 1, + content, + }) as LiveTextNode; +} + +type TextNodeSpec = { + text: string; + bold?: boolean; + mode?: TextModeType; + style?: string; +}; + +function createTextNodes( + editor: LexicalEditor, + specs: Array +): TextNode[] { + const keys: string[] = []; + + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + + for (const spec of specs) { + const node = + typeof spec === "string" + ? $createTextNode(spec) + : $createTextNode(spec.text); + + if (typeof spec !== "string") { + if (spec.bold) { + node.toggleFormat("bold"); + } + if (spec.mode !== undefined) { + node.setMode(spec.mode); + } + if (spec.style !== undefined) { + node.setStyle(spec.style); + } + } + + paragraph.append(node); + keys.push(node.getKey()); + } + }); + + return editor.read(() => + keys.map((key) => { + const node = $getNodeByKey(key); + if (node === null || !$isTextNode(node)) { + throw new Error("Expected TextNode"); + } + return node; + }) + ); +} diff --git a/packages/liveblocks-lexical/src/collaboration.ts b/packages/liveblocks-lexical/src/collaboration.ts new file mode 100644 index 00000000000..9756dc9b04b --- /dev/null +++ b/packages/liveblocks-lexical/src/collaboration.ts @@ -0,0 +1,257 @@ +import { mergeRegister } from "@lexical/utils"; +import type { Room } from "@liveblocks/client"; +import { + $getSelection, + $isRangeSelection, + COLLABORATION_TAG, + HISTORIC_TAG, + type LexicalEditor, +} from "lexical"; + +import { LiveblocksHistory } from "./history"; +import { LiveblocksCollaborationManager } from "./manager"; +import type { LiveLexicalSelection, LiveRootNode } from "./types"; + +export class LiveblocksCollaboration { + readonly editor: LexicalEditor; + readonly room: Room; + readonly root: LiveRootNode; + readonly manager: LiveblocksCollaborationManager; + readonly history: LiveblocksHistory; + + #unregister: (() => void) | null = null; + + constructor(editor: LexicalEditor, room: Room, root: LiveRootNode) { + this.editor = editor; + this.room = room; + this.root = root; + this.manager = new LiveblocksCollaborationManager(root, editor); + this.history = new LiveblocksHistory(editor, room, this.manager); + } + + register(): void { + if (this.#unregister !== null) { + return; + } + + const { editor, room, root, manager, history } = this; + + // History first — its update listener must run before Lexical → Storage + // so `pause()` wraps the mutations that follow. + history.register(); + + this.#unregister = mergeRegister( + editor.registerUpdateListener( + ({ + tags, + dirtyElements, + dirtyLeaves, + normalizedNodes, + editorState, + }) => { + if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) { + return; + } + + if (manager.binding.reverse.size === 0) { + return; + } + + try { + editorState.read(() => { + room.batch(() => { + manager.$applyLocalUpdates({ + dirtyElements: new Set(dirtyElements.keys()), + dirtyLeaves, + normalizedNodes, + }); + }); + }); + } catch (error) { + console.error("Failed to apply local changes to storage:", error); + } + } + ), + editor.registerUpdateListener(({ tags }) => { + if ( + tags.has(COLLABORATION_TAG) || + tags.has(HISTORIC_TAG) || + manager.binding.reverse.size === 0 + ) { + return; + } + + try { + editor.read(() => { + room.updatePresence({ selection: manager.$encodeSelection() }); + }); + } catch (error) { + console.error("Failed to publish selection presence:", error); + } + }), + () => { + room.updatePresence({ selection: null }); + }, + room.subscribe( + root, + (updates) => { + if (manager.binding.reverse.size === 0) { + return; + } + + if ( + updates.every((update) => { + const source = update.source; + return source.origin === "local" && source.via === "edit"; + }) + ) { + return; + } + + const isFromHistory = updates.some((update) => { + const source = update.source; + return ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ); + }); + + try { + editor.update( + () => { + manager.$applyRemoteUpdates(updates); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + if (isFromHistory) { + const restore = history.pendingRestore; + history.pendingRestore = null; + if (restore === null) { + return; + } + + // Prefer Lexical snapshot when that key is still usable in + // the active editor state — storage decode of a surviving + // LiveText can collapse offsets after delete/undo. When keys + // were recreated (common with multi-segment formatted text), + // use local flat offsets so we skip the remapping decodeIndex + // path. Presence storage decode remains last-resort. + const anchor = manager.$isUsableLexicalSnapshot( + restore.lexical.anchor + ) + ? restore.lexical.anchor + : (manager.$decodeLocalPoint(restore.local.anchor) ?? + manager.$decodePoint(restore.storage.anchor)); + const focus = manager.$isUsableLexicalSnapshot( + restore.lexical.focus + ) + ? restore.lexical.focus + : (manager.$decodeLocalPoint(restore.local.focus) ?? + manager.$decodePoint(restore.storage.focus)); + if (anchor === null || focus === null) { + return; + } + + selection.anchor.set(anchor.key, anchor.offset, anchor.type); + selection.focus.set(focus.key, focus.offset, focus.type); + return; + } + + const selection_presence = room.getPresence().selection as + | LiveLexicalSelection + | null + | undefined; + if ( + selection_presence === null || + selection_presence === undefined + ) { + return; + } + + const decoded = manager.$decodeSelection(selection_presence); + if (decoded === null) { + return; + } + + selection.anchor.set( + decoded.anchor.key, + decoded.anchor.offset, + decoded.anchor.type + ); + selection.focus.set( + decoded.focus.key, + decoded.focus.offset, + decoded.focus.type + ); + }, + { + // Do not pass `discrete: true`. Nested discrete updates throw + // inside Lexical command updates (undo/redo). + skipTransforms: true, + tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG, + onUpdate: () => { + editor.read(() => { + if (isFromHistory) { + const storage = manager.$encodeSelection(); + const selection = $getSelection(); + if (storage !== null && $isRangeSelection(selection)) { + const localAnchor = manager.$encodeLocalPoint( + selection.anchor + ); + const localFocus = manager.$encodeLocalPoint( + selection.focus + ); + if (localAnchor === null || localFocus === null) { + history.pendingBefore = null; + } else { + history.pendingBefore = { + storage, + local: { + anchor: localAnchor, + focus: localFocus, + }, + lexical: { + anchor: { + key: selection.anchor.key, + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focus: { + key: selection.focus.key, + offset: selection.focus.offset, + type: selection.focus.type, + }, + }, + }; + } + } else { + history.pendingBefore = null; + } + } + room.updatePresence({ + selection: manager.$encodeSelection(), + }); + }); + }, + } + ); + } catch (error) { + console.error("Failed to apply remote changes to editor:", error); + } + }, + { isDeep: true } + ), + () => { + history.unregister(); + } + ); + } + + unregister(): void { + this.#unregister?.(); + this.#unregister = null; + } +} diff --git a/packages/liveblocks-lexical/src/history.ts b/packages/liveblocks-lexical/src/history.ts new file mode 100644 index 00000000000..ef4b4f2716a --- /dev/null +++ b/packages/liveblocks-lexical/src/history.ts @@ -0,0 +1,365 @@ +import { mergeRegister } from "@lexical/utils"; +import type { Room } from "@liveblocks/client"; +import { kInternal } from "@liveblocks/core"; +import { + $getSelection, + $isRangeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + CLEAR_EDITOR_COMMAND, + CLEAR_HISTORY_COMMAND, + COLLABORATION_TAG, + COMMAND_PRIORITY_EDITOR, + type EditorState, + HISTORIC_TAG, + HISTORY_MERGE_TAG, + HISTORY_PUSH_TAG, + type LexicalEditor, + PASTE_TAG, + REDO_COMMAND, + UNDO_COMMAND, +} from "lexical"; + +import type { + DecodedLexicalSelection, + LiveblocksCollaborationManager, +} from "./manager"; +import type { LiveLexicalSelection } from "./types"; + +/** + * Idle window for merging local edits into one `room.history` stack item. + * Matches `@lexical/react` `useHistory` so Lexical users get familiar undo + * granularity; Our CodeMirror implementation uses 500ms for the same Storage mechanism. + */ +const HISTORY_CAPTURE_TIMEOUT_MS = 1000; + +/** + * Selection snapshot in three coordinate systems. + * + * - `storage` — presence encoding (`encodeIndex` + LiveText.version). Survives + * Lexical node-key recreation, but after delete+undo `decodeIndex` can remap + * the left edge of a deleted range on a surviving LiveText. + * - `lexical` — exact Lexical keys/offsets. Preferred when + * `$isUsableLexicalSnapshot` is true after historic reconcile. + * - `local` — LiveText character offsets / element child indices *before* + * `encodeIndex`. Used when Lexical keys were recreated (common with + * multi-segment formatted text) so we can place the caret without going + * through the remapping `decodeIndex` path. + */ +export type HistorySelectionSnapshot = { + storage: LiveLexicalSelection; + lexical: DecodedLexicalSelection; + local: LiveLexicalSelection; +}; + +type HistorySelectionEntry = { + before: HistorySelectionSnapshot | null; + after: HistorySelectionSnapshot | null; +}; + +export class LiveblocksHistory { + readonly #editor: LexicalEditor; + readonly #room: Room; + readonly #manager: LiveblocksCollaborationManager; + + #captureTimer: ReturnType | null = null; + /** True while we have called `pause()` and not yet committed via `resume()`. */ + #isCapturing = false; + + /** + * Candidate "before" while idle (not capturing). Updated on selection-only + * moves; ignored while a capture is open so mid-burst caret moves do not + * overwrite the item's before. + */ + #pendingBefore: HistorySelectionSnapshot | null = null; + + /** Locked when a capture opens — selection to restore on undo. */ + #before: HistorySelectionSnapshot | null = null; + + /** Latest selection during an open capture — selection to restore on redo. */ + #after: HistorySelectionSnapshot | null = null; + + /** Selection metadata keyed by private history stack item id. */ + readonly #historySelections = new Map(); + + /** + * One-shot restore target set on undo/redo. Sync reads and clears this + * after applying history-driven Storage updates. + */ + pendingRestore: HistorySelectionSnapshot | null = null; + + #unregister: (() => void) | null = null; + + constructor( + editor: LexicalEditor, + room: Room, + manager: LiveblocksCollaborationManager + ) { + this.#editor = editor; + this.#room = room; + this.#manager = manager; + } + + register(): void { + if (this.#unregister !== null) { + return; + } + + this.#dispatchCanUndoRedoCommands(); + + this.#unregister = mergeRegister( + this.#editor.registerUpdateListener( + ({ + editorState, + prevEditorState, + dirtyLeaves, + dirtyElements, + tags, + }) => { + if (tags.has(HISTORIC_TAG) || tags.has(COLLABORATION_TAG)) { + return; + } + + const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0; + + // Selection-only: no Storage mutation. Refresh idle before; leave + // an open capture alone so a caret move does not split typing. + if (!hasDirtyNodes) { + if (!this.#isCapturing) { + this.#pendingBefore = this.#encodeSelection(editorState); + } + return; + } + + // Explicit merge: stay in the current item even across idle. + if (tags.has(HISTORY_MERGE_TAG)) { + this.#beginOrExtendCapture(editorState, prevEditorState); + return; + } + + // Hard boundaries: paste and explicit push always start a new item. + const hardBoundary = + tags.has(HISTORY_PUSH_TAG) || tags.has(PASTE_TAG); + + if (hardBoundary || !this.#isCapturing) { + // `hardBoundary` while capturing: commit the previous item first. + // `!capturing`: commitCapture is a no-op; we still open below. + this.#commitCapture(); + } + + this.#beginOrExtendCapture(editorState, prevEditorState); + } + ), + this.#editor.registerCommand( + UNDO_COMMAND, + () => { + // Flush first — undo discards `pausedHistory`. + this.#commitCapture(); + if (!this.#room.history.canUndo()) { + return false; + } + this.#room.history.undo(); + this.#dispatchCanUndoRedoCommands(); + return true; + }, + COMMAND_PRIORITY_EDITOR + ), + this.#editor.registerCommand( + REDO_COMMAND, + () => { + this.#commitCapture(); + if (!this.#room.history.canRedo()) { + return false; + } + this.#room.history.redo(); + this.#dispatchCanUndoRedoCommands(); + return true; + }, + COMMAND_PRIORITY_EDITOR + ), + this.#editor.registerCommand( + CLEAR_HISTORY_COMMAND, + () => { + this.#commitCapture(); + this.#room.history.clear(); + this.#dispatchCanUndoRedoCommands(); + return true; + }, + COMMAND_PRIORITY_EDITOR + ), + this.#editor.registerCommand( + CLEAR_EDITOR_COMMAND, + () => { + this.#commitCapture(); + this.#room.history.clear(); + this.#dispatchCanUndoRedoCommands(); + return false; + }, + COMMAND_PRIORITY_EDITOR + ), + this.#room.subscribe("history", () => { + this.#dispatchCanUndoRedoCommands(); + }), + this.#room[kInternal].history.subscribe((event) => { + switch (event.action) { + case "push": { + this.#historySelections.set(event.id, { + before: this.#before, + after: this.#after, + }); + this.#before = null; + this.#after = null; + break; + } + case "undo": { + const entry = this.#historySelections.get(event.id); + this.pendingRestore = entry?.before ?? null; + break; + } + case "redo": { + const entry = this.#historySelections.get(event.id); + this.pendingRestore = entry?.after ?? null; + break; + } + case "discard": { + for (const id of event.ids) { + this.#historySelections.delete(id); + } + break; + } + case "clear": { + this.#historySelections.clear(); + this.pendingRestore = null; + break; + } + } + }), + () => { + this.#commitCapture(); + } + ); + } + + unregister(): void { + this.#unregister?.(); + this.#unregister = null; + } + + set pendingBefore(selection: HistorySelectionSnapshot | null) { + this.#pendingBefore = selection; + } + + #dispatchCanUndoRedoCommands(): void { + this.#editor.dispatchCommand( + CAN_UNDO_COMMAND, + this.#room.history.canUndo() + ); + this.#editor.dispatchCommand( + CAN_REDO_COMMAND, + this.#room.history.canRedo() + ); + } + + #clearCaptureTimer(): void { + if (this.#captureTimer === null) { + return; + } + clearTimeout(this.#captureTimer); + this.#captureTimer = null; + } + + /** + * Commit the open pause group onto the undo stack. Safe when history was + * never paused (`resume` is a no-op on an empty paused buffer). + * + * Leaves `#before` / `#after` in place so the synchronous `push` event can + * store them; that handler clears the pair. + */ + #commitCapture(): void { + this.#clearCaptureTimer(); + if (!this.#isCapturing) { + return; + } + this.#isCapturing = false; + // Next item's candidate before is where this item left the caret. + if (this.#after !== null) { + this.#pendingBefore = this.#after; + } + this.#room.history.resume(); + } + + #scheduleCommit(): void { + this.#clearCaptureTimer(); + this.#captureTimer = setTimeout(() => { + this.#captureTimer = null; + this.#isCapturing = false; + if (this.#after !== null) { + this.#pendingBefore = this.#after; + } + this.#room.history.resume(); + this.#dispatchCanUndoRedoCommands(); + }, HISTORY_CAPTURE_TIMEOUT_MS); + } + + /** Open or extend a paused history group and arm the idle commit timer. */ + #beginOrExtendCapture( + editorState: EditorState, + prevEditorState: EditorState | null + ): void { + if (!this.#isCapturing) { + this.#before = + this.#pendingBefore ?? this.#encodeSelection(prevEditorState); + } + + this.#isCapturing = true; + this.#after = this.#encodeSelection(editorState); + this.#room.history.pause(); + this.#scheduleCommit(); + } + + #encodeSelection( + editorState: EditorState | null + ): HistorySelectionSnapshot | null { + if (editorState === null) { + return null; + } + if (this.#manager.binding.reverse.size === 0) { + return null; + } + + return editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + const storageAnchor = this.#manager.$encodePoint(selection.anchor); + const storageFocus = this.#manager.$encodePoint(selection.focus); + const localAnchor = this.#manager.$encodeLocalPoint(selection.anchor); + const localFocus = this.#manager.$encodeLocalPoint(selection.focus); + if ( + storageAnchor === null || + storageFocus === null || + localAnchor === null || + localFocus === null + ) { + return null; + } + return { + storage: { anchor: storageAnchor, focus: storageFocus }, + local: { anchor: localAnchor, focus: localFocus }, + lexical: { + anchor: { + key: selection.anchor.key, + offset: selection.anchor.offset, + type: selection.anchor.type, + }, + focus: { + key: selection.focus.key, + offset: selection.focus.offset, + type: selection.focus.type, + }, + }, + }; + }); + } +} diff --git a/packages/liveblocks-lexical/src/index.ts b/packages/liveblocks-lexical/src/index.ts new file mode 100644 index 00000000000..be33c4c09b4 --- /dev/null +++ b/packages/liveblocks-lexical/src/index.ts @@ -0,0 +1,10 @@ +import { detectDupes } from "@liveblocks/core"; + +import { PKG_FORMAT, PKG_NAME, PKG_VERSION } from "./version"; + +detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); + +export type { LiveblocksCollaborationPluginProps } from "./react/liveblocks-collaboration-plugin"; +export { LiveblocksCollaborationPlugin } from "./react/liveblocks-collaboration-plugin"; +export { RemoteCursorsPlugin } from "./react/remote-cursors"; +export type { LiveLexicalSelection, LiveRootNode } from "./types"; diff --git a/packages/liveblocks-lexical/src/manager.ts b/packages/liveblocks-lexical/src/manager.ts new file mode 100644 index 00000000000..03bcfa33d35 --- /dev/null +++ b/packages/liveblocks-lexical/src/manager.ts @@ -0,0 +1,3567 @@ +import { + type Json, + LiveList, + LiveMap, + LiveObject, + LiveText, + type StorageUpdate, +} from "@liveblocks/client"; +import { + type JsonObject, + kInternal, + type PrivateLiveNodeApi, + type TextAttributes, +} from "@liveblocks/core"; +import { + $createLineBreakNode, + $getEditor, + $getNodeByKey, + $getRoot, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isRangeSelection, + $isRootNode, + $isTextNode, + COLLABORATION_TAG, + type DecoratorNode, + type ElementNode, + type LexicalEditor, + type LexicalNode, + type LexicalUpdateJSON, + type LineBreakNode, + NODE_STATE_KEY, + type NodeKey, + type Point, + type RootNode, + type SerializedElementNode, + type SerializedTextNode, + TEXT_TYPE_TO_FORMAT, + type TextNode, +} from "lexical"; + +import type { + LiveChildNode, + LiveDecoratorNode, + LiveDecoratorShape, + LiveElementNode, + LiveElementShape, + LiveLexicalPoint, + LiveLexicalSelection, + LiveLineBreakNode, + LiveLineBreakShape, + LiveRootChildNode, + LiveRootNode, + LiveRootShape, + LiveStorageNode, + LiveTextNode, + LiveTextShape, +} from "./types"; + +export type DecodedLexicalPoint = { + key: NodeKey; + offset: number; + type: LiveLexicalPoint["type"]; +}; + +export type DecodedLexicalSelection = { + anchor: DecodedLexicalPoint; + focus: DecodedLexicalPoint; +}; + +export class LiveblocksCollaborationManager { + #binding: { + /** Storage node → Lexical node (or coalesced TextNode[] for text children). */ + forward: WeakMap< + LiveStorageNode, + | RootNode + | ElementNode + | LineBreakNode + | DecoratorNode + | readonly TextNode[] + >; + /** Lexical NodeKey → source storage node. */ + reverse: Map; + }; + private root: LiveRootNode; + constructor(root: LiveRootNode, editor: LexicalEditor) { + this.root = root; + this.#binding = { + forward: new WeakMap(), + reverse: new Map(), + }; + + editor.update( + () => { + const root_lexical = $getRoot(); + root_lexical.clear(); + const children: Array> = []; + for (const child of this.root.get("children")) { + const kind = child.get("kind"); + if (kind === "decorator") { + children.push( + $convertLiveDecoratorNodeToLexicalNode(child as LiveDecoratorNode) + ); + } else if (kind === "element") { + children.push( + $convertLiveElementNodeToLexicalNode(child as LiveElementNode) + ); + } else { + throw new Error( + `Unsupported root child kind "${String(kind)}". Expected "element" or "decorator".` + ); + } + } + root_lexical.append(...children); + this.$updateBinding(); + }, + { tag: COLLABORATION_TAG, skipTransforms: true } + ); + } + + get binding(): Readonly<{ + forward: Readonly< + WeakMap< + LiveStorageNode, + | RootNode + | ElementNode + | LineBreakNode + | DecoratorNode + | readonly TextNode[] + > + >; + reverse: ReadonlyMap; + }> { + return this.#binding; + } + + private $updateBinding(): void { + const forward = new WeakMap< + LiveStorageNode, + | RootNode + | ElementNode + | LineBreakNode + | DecoratorNode + | readonly TextNode[] + >(); + const reverse = new Map(); + + this.#binding = { + forward, + reverse, + }; + + const root = $getRoot(); + forward.set(this.root, root); + reverse.set(root.getKey(), this.root); + + let index = 0; + for (const child of this.root.get("children")) { + const node_lexical = root.getChildren()[index]; + if (child.get("kind") === "decorator") { + this.createBinding( + child as LiveDecoratorNode, + node_lexical as DecoratorNode + ); + } else { + this.createBinding( + child as LiveElementNode, + node_lexical as ElementNode + ); + } + index++; + } + } + + /** + * Encode a Lexical selection endpoint as a storage-relative presence point. + * + * Must be called inside `editor.read()` / `editor.update()` while bindings + * are populated. Returns `null` when the point cannot be expressed in + * storage coordinates (unbound node, missing node id, unsupported point + * type, or a text point whose reverse binding is not a LiveText). + */ + public $encodePoint(point: Point): LiveLexicalPoint | null { + const node_liveblocks = this.#binding.reverse.get(point.key); + if (node_liveblocks === undefined) { + return null; + } + + if (point.type === "text") { + return this.$encodeTextPoint(point, node_liveblocks); + } + + if (point.type === "element") { + return this.$encodeElementPoint(point, node_liveblocks); + } + + return null; + } + + /** + * Encode the current range selection for Liveblocks presence. + * + * Returns `null` when there is no range selection or either endpoint cannot + * be encoded. + */ + public $encodeSelection(): LiveLexicalSelection | null { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return null; + } + + const anchor = this.$encodePoint(selection.anchor); + const focus = this.$encodePoint(selection.focus); + if (anchor === null || focus === null) { + return null; + } + + return { anchor, focus }; + } + + /** + * Encode a Lexical text point into a storage-relative presence point. + * + * Sibling TextNodes that share the same LiveText binding are flattened into + * one LiveText character offset (matching how storage coalesces formatted + * spans). Adjacent TextNodes bound to *different* LiveText children must not + * contribute to each other's offset — that happens with concurrent remote + * inserts of separate text children. + */ + public $encodeTextPoint( + point: Point, + node_liveblocks: LiveStorageNode + ): LiveLexicalPoint | null { + const local = this.$encodeLocalTextPoint(point, node_liveblocks); + if (local === null) { + return null; + } + + const liveText = (node_liveblocks as LiveTextNode).get("content"); + return { + ...local, + offset: liveText[kInternal].encodeIndex(local.offset), + version: liveText.version, + }; + } + + /** + * Encode a Lexical point in local document coordinates (no `encodeIndex`). + * + * For text points the offset is the flat LiveText character index across + * coalesced siblings. For element points this matches `$encodePoint`. + * Used by history restore when Lexical keys were recreated and presence + * `decodeIndex` would remap the left edge of a deleted range. + */ + public $encodeLocalPoint(point: Point): LiveLexicalPoint | null { + const node_liveblocks = this.#binding.reverse.get(point.key); + if (node_liveblocks === undefined) { + return null; + } + + if (point.type === "text") { + return this.$encodeLocalTextPoint(point, node_liveblocks); + } + + if (point.type === "element") { + return this.$encodeElementPoint(point, node_liveblocks); + } + + return null; + } + + /** + * Decode a local-document point (flat LiveText offset / element child index) + * into Lexical coordinates. Unlike `$decodePoint`, text offsets skip + * `LiveText.decodeIndex` — they are already in local document space. + */ + public $decodeLocalPoint( + point: LiveLexicalPoint + ): DecodedLexicalPoint | null { + const node_liveblocks = find_liveblocksNode( + this.root, + (candidate) => + point.nodeId === + (candidate as unknown as { [kInternal]: PrivateLiveNodeApi })[ + kInternal + ].getId() + ); + if (node_liveblocks === null) { + return null; + } + + if (point.type === "text") { + if (node_liveblocks.get("kind") !== "text") { + return null; + } + return this.$decodeFlatTextOffset( + point.offset, + node_liveblocks as LiveTextNode + ); + } + + if (point.type === "element") { + const kind = node_liveblocks.get("kind"); + if (kind !== "element" && kind !== "root") { + return null; + } + return this.$decodeElementPoint(point, node_liveblocks); + } + + return null; + } + + private $encodeLocalTextPoint( + point: Point, + node_liveblocks: LiveStorageNode + ): LiveLexicalPoint | null { + if (node_liveblocks.get("kind") !== "text") { + return null; + } + + const nodeId = ( + node_liveblocks as unknown as { [kInternal]: PrivateLiveNodeApi } + )[kInternal].getId(); + if (nodeId === undefined) { + return null; + } + + const node_lexical = point.getNode(); + if (!$isTextNode(node_lexical)) { + return null; + } + + // Accumulate only previous text siblings that belong to THIS LiveText. + let flatOffset = point.offset; + let prevSibling = node_lexical.getPreviousSibling(); + while ( + $isTextNode(prevSibling) && + this.#binding.reverse.get(prevSibling.getKey()) === node_liveblocks + ) { + flatOffset += prevSibling.getTextContentSize(); + prevSibling = prevSibling.getPreviousSibling(); + } + + return { + nodeId, + type: "text", + offset: flatOffset, + version: 0, + }; + } + + /** + * Encode a Lexical element point into a storage-relative presence point. + * + * Lexical child indices count every TextNode; storage children coalesce + * consecutive TextNodes that share one LiveText binding into a single slot. + * Distinct adjacent LiveText bindings remain separate slots. + */ + public $encodeElementPoint( + point: Point, + node_liveblocks: LiveStorageNode + ): LiveLexicalPoint | null { + const kind = node_liveblocks.get("kind"); + if (kind !== "element" && kind !== "root") { + return null; + } + + const nodeId = ( + node_liveblocks as unknown as { [kInternal]: PrivateLiveNodeApi } + )[kInternal].getId(); + if (nodeId === undefined) { + return null; + } + + const node_lexical = point.getNode(); + if (!$isElementNode(node_lexical)) { + return null; + } + + const storageOffset = this.$convertLexicalChildIndexToStorage( + node_lexical, + point.offset + ); + if (storageOffset === null) { + return null; + } + + return { + nodeId, + type: "element", + offset: storageOffset, + version: 0, + }; + } + + /** + * Decode a storage-relative presence point into Lexical coordinates. + * + * Must be called inside `editor.read()` / `editor.update()` while bindings + * are populated. Returns `null` when the storage node is missing, unbound, + * the point type does not match the node, or the point cannot be decoded yet + * (e.g. peer ahead on LiveText version). + */ + public $decodePoint(point: LiveLexicalPoint): DecodedLexicalPoint | null { + const node_liveblocks = find_liveblocksNode( + this.root, + (candidate) => + point.nodeId === + (candidate as unknown as { [kInternal]: PrivateLiveNodeApi })[ + kInternal + ].getId() + ); + if (node_liveblocks === null) { + return null; + } + + if (point.type === "text") { + if (node_liveblocks.get("kind") !== "text") { + return null; + } + return this.$decodeTextPoint(point, node_liveblocks as LiveTextNode); + } + + if (point.type === "element") { + const kind = node_liveblocks.get("kind"); + if (kind !== "element" && kind !== "root") { + return null; + } + return this.$decodeElementPoint(point, node_liveblocks); + } + + return null; + } + + /** + * Decode a storage-relative presence selection into Lexical coordinates. + * + * Returns `null` when either endpoint cannot be decoded. + */ + public $decodeSelection( + selection: LiveLexicalSelection + ): DecodedLexicalSelection | null { + const anchor = this.$decodePoint(selection.anchor); + const focus = this.$decodePoint(selection.focus); + if (anchor === null || focus === null) { + return null; + } + + return { anchor, focus }; + } + + /** + * True when a decoded Lexical snapshot point is still safe for `Point.set`: + * bound, present in the active editor state, attached, and matching type. + * `binding.reverse.has(key)` alone is not enough — reconcile can leave + * reverse entries for detached TextNodes. + */ + public $isUsableLexicalSnapshot(point: DecodedLexicalPoint): boolean { + if (!this.#binding.reverse.has(point.key)) { + return false; + } + const node = $getNodeByKey(point.key); + if (node === null || !node.isAttached()) { + return false; + } + return point.type === "text" ? $isTextNode(node) : $isElementNode(node); + } + + /** + * Decode a storage-relative text point into a Lexical text point. + * + * `LiveText.decodeIndex` maps the presence offset into local document + * coordinates (accounting for accepted ops since `point.version` and any + * local pending ops). The flat offset is then split across the coalesced + * TextNode[] bound to this LiveText. + * + * Binding entries are re-resolved with `$getNodeByKey` — the forward map + * may still hold TextNode refs from a prior editor state after structural + * deletes (same invariant as `$reconcileTextNodeFromLexical`). + */ + private $decodeTextPoint( + point: LiveLexicalPoint, + node_liveblocks: LiveTextNode + ): DecodedLexicalPoint | null { + const liveText = node_liveblocks.get("content"); + const flatOffset = liveText[kInternal].decodeIndex( + point.offset, + point.version + ); + if (flatOffset === null) { + return null; + } + + return this.$decodeFlatTextOffset(flatOffset, node_liveblocks); + } + + /** + * Place a flat LiveText character offset onto the coalesced TextNode[] + * bound to `node_liveblocks`. Does not call `decodeIndex`. + */ + private $decodeFlatTextOffset( + flatOffset: number, + node_liveblocks: LiveTextNode + ): DecodedLexicalPoint | null { + const coalesced = this.#binding.forward.get(node_liveblocks); + if (coalesced === undefined || !(coalesced instanceof Array)) { + return null; + } + + // Drop detached / missing keys before walking — calling methods on a + // stale TextNode throws ("Lexical node does not exist in active editor + // state") via getLatest(). + const textNodes = coalesced + .map((node) => $getNodeByKey(node.getKey())) + .filter( + (node): node is TextNode => + node !== null && $isTextNode(node) && node.isAttached() + ); + if (textNodes.length === 0) { + return null; + } + + // Walk coalesced TextNodes until the flat offset lands in one. + // Use `>` (not `>=`) so an offset exactly at a node boundary stays at + // the end of that node — matching encode's "sum previous siblings" rule + // for a caret at the start of the next sibling. + // + // @example Coalesced ["Hello ", "world"], flatOffset = 6 + // t0 "Hello " (size 6): 6 > 6? no → { key: t0, offset: 6 } + // @example flatOffset = 7 + // t0 size 6: 7 > 6 → remaining 1; t1 "world": 1 > 5? no → { key: t1, offset: 1 } + let remaining = flatOffset; + let index = 0; + while ( + remaining > textNodes[index].getTextContentSize() && + index + 1 < textNodes.length + ) { + remaining -= textNodes[index].getTextContentSize(); + index += 1; + } + + const textNode = textNodes[index]; + return { + key: textNode.getKey(), + offset: Math.min(remaining, textNode.getTextContentSize()), + type: "text", + }; + } + + /** + * Decode a storage-relative element point into a Lexical element point. + * + * Storage child indices count coalesced LiveText slots; Lexical child + * indices count every TextNode. Inverse of `$convertLexicalChildIndexToStorage`. + * + * Binding entries are re-resolved with `$getNodeByKey` — the forward map + * may still hold an ElementNode ref from a prior editor state. + */ + private $decodeElementPoint( + point: LiveLexicalPoint, + node_liveblocks: LiveStorageNode + ): DecodedLexicalPoint | null { + const mapped = this.#binding.forward.get(node_liveblocks); + if (mapped === undefined || mapped instanceof Array) { + return null; + } + + const element = $getNodeByKey(mapped.getKey()); + if (element === null || !$isElementNode(element) || !element.isAttached()) { + return null; + } + + const lexicalOffset = this.$convertStorageOffsetToLexicalChildIndex( + element, + point.offset + ); + if (lexicalOffset === null) { + return null; + } + + return { + key: element.getKey(), + offset: lexicalOffset, + type: "element", + }; + } + + /** + * Map a Lexical element child index to the corresponding LiveList child index. + * + * Consecutive TextNodes coalesce into one storage slot only while they share + * the same reverse binding. A binding change starts a new storage slot — + * matching how concurrent inserts produce adjacent distinct LiveText children. + * + * Returns `null` when a text child before the target index is unbound: the + * caret is not yet expressible in storage coordinates. + * + * @example Coalesced text + linebreak + * + * Lexical children: [ t0 "Hi" bold, t1 "there", br ] + * └──── same LiveText ────┘ + * Lexical indices: 0 1 2 + * Storage children: [ text (LiveText), linebreak ] + * Storage indices: 0 1 + * + * targetChildIndex = 0 → 0 (before first slot) + * targetChildIndex = 1 → 0 (still inside coalesced text; t1 skipped) + * targetChildIndex = 2 → 1 (after the LiveText slot) + * targetChildIndex = 3 → 2 (after linebreak) + * + * @example Adjacent distinct LiveText children + * + * Lexical children: [ t0 "foo" bold, t1 "bar" ] + * └ LiveText A ┘ └ LiveText B ┘ + * Lexical indices: 0 1 + * Storage children: [ text A, text B ] + * Storage indices: 0 1 + * + * targetChildIndex = 0 → 0 + * targetChildIndex = 1 → 1 (binding changed; do NOT coalesce) + * targetChildIndex = 2 → 2 + */ + private $convertLexicalChildIndexToStorage( + element: ElementNode, + targetChildIndex: number + ): number | null { + const children = element.getChildren(); + let index_liveblocks = 0; + let index_lexical = 0; + + while (index_lexical < targetChildIndex) { + if (index_lexical >= children.length) { + return index_liveblocks; + } + + const child = children[index_lexical]; + index_lexical += 1; + + if ($isTextNode(child)) { + const binding = this.#binding.reverse.get(child.getKey()); + if (binding === undefined) { + return null; + } + while (index_lexical < children.length) { + const next = children[index_lexical]; + if (!$isTextNode(next)) { + break; + } + if (this.#binding.reverse.get(next.getKey()) !== binding) { + break; + } + index_lexical += 1; + } + } + + index_liveblocks += 1; + } + + return index_liveblocks; + } + + /** + * Map a LiveList child index to the corresponding Lexical element child index. + * + * Inverse of {@link $convertLexicalChildIndexToStorage}: for each storage + * slot consumed, advance past one Lexical child — or past a whole run of + * TextNodes that share the same LiveText binding. + * + * Returns `null` when a text child encountered while walking is unbound. + * + * @example Coalesced text + linebreak + * + * Lexical children: [ t0 "Hi" bold, t1 "there", br ] + * └──── same LiveText ────┘ + * Storage children: [ text (LiveText), linebreak ] + * + * storageOffset = 0 → 0 (before first slot) + * storageOffset = 1 → 2 (skip t0+t1; land before br) + * storageOffset = 2 → 3 (after br) + * + * @example Adjacent distinct LiveText children + * + * Lexical children: [ t0 "foo" bold, t1 "bar" ] + * └ LiveText A ┘ └ LiveText B ┘ + * Storage children: [ text A, text B ] + * + * storageOffset = 0 → 0 + * storageOffset = 1 → 1 (only skip t0; t1 is a different binding) + * storageOffset = 2 → 2 + */ + private $convertStorageOffsetToLexicalChildIndex( + element: ElementNode, + storageOffset: number + ): number | null { + const children = element.getChildren(); + let remaining = storageOffset; + let index_lexical = 0; + + while (remaining > 0 && index_lexical < children.length) { + const child = children[index_lexical]; + index_lexical += 1; + + if ($isTextNode(child)) { + const binding = this.#binding.reverse.get(child.getKey()); + if (binding === undefined) { + return null; + } + while (index_lexical < children.length) { + const next = children[index_lexical]; + if (!$isTextNode(next)) { + break; + } + if (this.#binding.reverse.get(next.getKey()) !== binding) { + break; + } + index_lexical += 1; + } + } + + remaining -= 1; + } + + return index_lexical; + } + + public $applyLocalUpdates(changeset: { + dirtyElements: ReadonlySet; + dirtyLeaves: ReadonlySet; + normalizedNodes: ReadonlySet; + }) { + const dirtyElements = changeset.dirtyElements; + if (!dirtyElements.has("root")) return; + + if (this.#binding.reverse.size === 0) return; + + const dirtyNodes = new Set([ + ...dirtyElements, + ...changeset.dirtyLeaves, + ]); + + this.$reconcileElementNodeFromLexical( + $getRoot(), + this.root as unknown as LiveElementNode, + dirtyNodes + ); + } + + public $reconcileElementNodeFromLexical( + node_lexical: RootNode, + node_liveblocks: LiveRootNode, + dirtyNodes: ReadonlySet + ): void; + public $reconcileElementNodeFromLexical( + node_lexical: ElementNode, + node_liveblocks: LiveElementNode, + dirtyNodes: ReadonlySet + ): void; + public $reconcileElementNodeFromLexical( + node_lexical: RootNode | ElementNode, + node_liveblocks: LiveRootNode | LiveElementNode, + dirtyNodes: ReadonlySet + ): void { + node_lexical = node_lexical.getLatest(); + this.#binding.forward.set(node_liveblocks, node_lexical); + this.#binding.reverse.set(node_lexical.getKey(), node_liveblocks); + + if ($isElementNode(node_lexical)) { + node_liveblocks = node_liveblocks as LiveElementNode; + const type_lexical = node_lexical.getType(); + if (node_liveblocks.get("type") !== type_lexical) { + node_liveblocks.set("type", type_lexical); + } + + const props_lexical = $getLexicalNodeProps(node_lexical); + const props_liveblocks = node_liveblocks.get("props"); + const props_liveblocks_json = + props_liveblocks !== undefined ? props_liveblocks.toJSON() : undefined; + if ( + !isEqual(props_lexical, props_liveblocks_json as JsonObject | undefined) + ) { + if (props_lexical === undefined) { + node_liveblocks.delete("props"); + } else if (!(props_liveblocks instanceof LiveMap)) { + node_liveblocks.set( + "props", + new LiveMap( + Object.entries(props_lexical).filter( + (entry): entry is [string, Json] => entry[1] !== undefined + ) + ) + ); + } else { + const keys = new Set(Object.keys(props_lexical)); + for (const key of props_liveblocks.keys()) { + if (!keys.has(key)) { + props_liveblocks.delete(key); + } + } + for (const [key, value] of Object.entries(props_lexical)) { + if (value === undefined) { + continue; + } + if (props_liveblocks.get(key) !== value) { + props_liveblocks.set(key, value); + } + } + } + } + } + + const children_lexical = $normalizeLexicalChildren(node_lexical); + const children_liveblocks: LiveList = ( + node_liveblocks as LiveObject + ).get("children"); + + const numOfItems_lexical = children_lexical.length; + const numOfItems_liveblocks = children_liveblocks.length; + const minCount = Math.min(numOfItems_lexical, numOfItems_liveblocks); + + let left = 0; // Stores the count of matching children from the start + let right = 0; // Stores the count of matching children from the end + + /** + * Scan from the left to find unchanged prefix ('left' pointer). Walks children from + * index 0 forward and asks: “How many slots at the start are already in sync?” + * + * When the loop exits, 'left' = length of the longest matching prefix where each pair is either: + * - already mapped to the same Lexical object + * - structurally equal (e.g. same node type, attributes, slots, and recursively its children) + * + * children (Lexical) [ A , B , C , D ] + * children (Storage) [ A , X , C , D ] + * ↑ + * left = 0 → compare A vs A → continue + * left = 1 → compare B vs X → break + */ + for (; left < minCount; left++) { + const child_liveblocks = children_liveblocks.get(left); + const child_lexical = children_lexical[left]; + + if (child_liveblocks === undefined) break; + + const kind_liveblocks = child_liveblocks.get("kind"); + + if (child_lexical instanceof Array) { + if (kind_liveblocks !== "text") break; + + const text_liveblocks = child_liveblocks as LiveTextNode; + // Get the Lexical node that is mapped to the current storage child. + const text_lexical = this.#binding.forward.get(text_liveblocks); + if ( + $isTextNodeList(text_lexical) && + areListsEqual(text_lexical, child_lexical) + ) { + continue; + } + + // If the text nodes are structurally equal, we create a binding between the liveblocks and lexical nodes. + if (areTextNodesStructurallyEqual(text_liveblocks, child_lexical)) { + this.createBinding(text_liveblocks, child_lexical); + continue; + } + break; + } else { + if (kind_liveblocks === "text") break; + + const element_liveblocks = child_liveblocks as LiveElementNode; + // Get the Lexical node that is mapped to the current storage child. + const element_lexical = this.#binding.forward.get(element_liveblocks); + + if ($isElementNode(child_lexical)) { + if (kind_liveblocks !== "element") break; + + // If the mapped lexical node is an element node and is the same reference + // as the current Lexical node, we recursively reconcile the node to synchronize + // the children, properties, etc. + if ( + $isLexicalNode(element_lexical) && + $isElementNode(element_lexical) && + element_lexical === child_lexical + ) { + if (dirtyNodes.has(child_lexical.getKey())) { + this.$reconcileElementNodeFromLexical( + element_lexical, + element_liveblocks, + dirtyNodes + ); + } + continue; + } + + if ( + areElementNodesStructurallyEqual(element_liveblocks, child_lexical) + ) { + this.createBinding(element_liveblocks, child_lexical); + continue; + } + + break; + } else if ($isLineBreakNode(child_lexical)) { + if (kind_liveblocks !== "linebreak") break; + + const linebreak_liveblocks = child_liveblocks as LiveLineBreakNode; + this.createBinding(linebreak_liveblocks, child_lexical); + continue; + } else if ($isDecoratorNode(child_lexical)) { + if (kind_liveblocks !== "decorator") break; + + const decorator_liveblocks = child_liveblocks as LiveDecoratorNode; + const decorator_lexical = + this.#binding.forward.get(decorator_liveblocks); + + if ( + $isLexicalNode(decorator_lexical) && + $isDecoratorNode(decorator_lexical) && + decorator_lexical === child_lexical + ) { + if (dirtyNodes.has(child_lexical.getKey())) { + this.$reconcileDecoratorNodeFromLexical( + child_lexical, + decorator_liveblocks + ); + } + continue; + } + + if ( + areDecoratorNodesStructurallyEqual( + decorator_liveblocks, + child_lexical + ) + ) { + this.createBinding(decorator_liveblocks, child_lexical); + continue; + } + + break; + } else { + break; + } + } + } + + /** + * Scan from the right to find unchanged suffix ('right' pointer). Same rules as + * the left scan, but pairs children from the end inward. Stops when left + right + * would overlap (middle region is handled later). + * + * children (Lexical) [ A , B , C , D ] + * children (Storage) [ A , X , C , D ] + * ↑ + * right = 0 → compare D vs D → continue + * right = 1 → compare C vs C → continue + * right = 2 → would overlap with left → loop ends + */ + for (; left + right < minCount; right++) { + const child_liveblocks = children_liveblocks.get( + numOfItems_liveblocks - right - 1 + ); + const child_lexical = children_lexical[numOfItems_lexical - right - 1]; + if (child_liveblocks === undefined) break; + + const kind_liveblocks = child_liveblocks.get("kind"); + + if (child_lexical instanceof Array) { + if (kind_liveblocks !== "text") break; + const text_liveblocks = child_liveblocks as LiveTextNode; + const text_lexical = this.#binding.forward.get(text_liveblocks); + + if ( + $isTextNodeList(text_lexical) && + areListsEqual(text_lexical, child_lexical) + ) { + continue; + } + + if (areTextNodesStructurallyEqual(text_liveblocks, child_lexical)) { + this.createBinding(text_liveblocks, child_lexical); + continue; + } + break; + } else { + if (kind_liveblocks === "text") break; + + const element_liveblocks = child_liveblocks as LiveElementNode; + const element_lexical = this.#binding.forward.get(element_liveblocks); + + if ($isElementNode(child_lexical)) { + if (kind_liveblocks !== "element") break; + if ( + $isLexicalNode(element_lexical) && + $isElementNode(element_lexical) && + element_lexical === child_lexical + ) { + if (dirtyNodes.has(child_lexical.getKey())) { + this.$reconcileElementNodeFromLexical( + element_lexical, + element_liveblocks, + dirtyNodes + ); + } + continue; + } + + if ( + areElementNodesStructurallyEqual(element_liveblocks, child_lexical) + ) { + this.createBinding(element_liveblocks, child_lexical); + continue; + } + + break; + } else if ($isLineBreakNode(child_lexical)) { + if (kind_liveblocks !== "linebreak") break; + const linebreak_liveblocks = child_liveblocks as LiveLineBreakNode; + this.createBinding(linebreak_liveblocks, child_lexical); + continue; + } else if ($isDecoratorNode(child_lexical)) { + if (kind_liveblocks !== "decorator") break; + + const decorator_liveblocks = child_liveblocks as LiveDecoratorNode; + const decorator_lexical = + this.#binding.forward.get(decorator_liveblocks); + + if ( + $isLexicalNode(decorator_lexical) && + $isDecoratorNode(decorator_lexical) && + decorator_lexical === child_lexical + ) { + if (dirtyNodes.has(child_lexical.getKey())) { + this.$reconcileDecoratorNodeFromLexical( + child_lexical, + decorator_liveblocks + ); + } + continue; + } + + if ( + areDecoratorNodesStructurallyEqual( + decorator_liveblocks, + child_lexical + ) + ) { + this.createBinding(decorator_liveblocks, child_lexical); + continue; + } + + break; + } else { + break; + } + } + } + + while ( + numOfItems_lexical > left + right && + numOfItems_liveblocks > left + right + ) { + const child_liveblocks_left = children_liveblocks.get(left)!; + const child_lexical_left = children_lexical[left]; + + const kind_liveblocks = child_liveblocks_left.get("kind"); + + if (kind_liveblocks === "text" && $isTextNodeList(child_lexical_left)) { + this.$reconcileTextNodeFromLexical( + child_lexical_left, + child_liveblocks_left as LiveTextNode + ); + left++; + continue; + } + + const isLeftElementSameType = + kind_liveblocks === "element" && + $isLexicalNode(child_lexical_left) && + $isElementNode(child_lexical_left) && + (child_liveblocks_left as LiveElementNode).get("type") === + child_lexical_left.getType(); + + const child_liveblocks_right = children_liveblocks.get( + children_liveblocks.length - right - 1 + )!; + const child_lexical_right = + children_lexical[numOfItems_lexical - right - 1]; + const kind_liveblocks_right = child_liveblocks_right.get("kind"); + + const isRightElementSameType = + kind_liveblocks_right === "element" && + $isLexicalNode(child_lexical_right) && + $isElementNode(child_lexical_right) && + (child_liveblocks_right as LiveElementNode).get("type") === + child_lexical_right.getType(); + + if (isLeftElementSameType && !isRightElementSameType) { + this.$reconcileElementNodeFromLexical( + child_lexical_left, + child_liveblocks_left as LiveElementNode, + dirtyNodes + ); + left++; + continue; + } else if (!isLeftElementSameType && isRightElementSameType) { + this.$reconcileElementNodeFromLexical( + child_lexical_right, + child_liveblocks_right as LiveElementNode, + dirtyNodes + ); + right++; + continue; + } else if (isLeftElementSameType && isRightElementSameType) { + const counts_left = this.$getChildDiffOverlap( + child_liveblocks_left as LiveElementNode, + child_lexical_left + ); + const counts_right = this.$getChildDiffOverlap( + child_liveblocks_right as LiveElementNode, + child_lexical_right + ); + const overlap_left = + counts_left.numOfMatchingPrefix + counts_left.numOfMatchingSuffix; + const overlap_right = + counts_right.numOfMatchingPrefix + counts_right.numOfMatchingSuffix; + if ( + counts_left.numOfIdenticalChildren > 0 && + counts_right.numOfIdenticalChildren === 0 + ) { + this.$reconcileElementNodeFromLexical( + child_lexical_left, + child_liveblocks_left as LiveElementNode, + dirtyNodes + ); + left++; + } else if ( + counts_left.numOfIdenticalChildren === 0 && + counts_right.numOfIdenticalChildren > 0 + ) { + this.$reconcileElementNodeFromLexical( + child_lexical_right, + child_liveblocks_right as LiveElementNode, + dirtyNodes + ); + right++; + } else if (overlap_left < overlap_right) { + this.$reconcileElementNodeFromLexical( + child_lexical_right, + child_liveblocks_right as LiveElementNode, + dirtyNodes + ); + right++; + } else { + this.$reconcileElementNodeFromLexical( + child_lexical_left, + child_liveblocks_left as LiveElementNode, + dirtyNodes + ); + left++; + } + continue; + } else if ( + // Decorators are leaves — no child-overlap scoring. Same-type on the + // left is enough; a right-only match reaches this branch on a later + // iteration after the left slot is replaced. + kind_liveblocks === "decorator" && + $isLexicalNode(child_lexical_left) && + $isDecoratorNode(child_lexical_left) && + (child_liveblocks_left as LiveDecoratorNode).get("type") === + child_lexical_left.getType() + ) { + this.$reconcileDecoratorNodeFromLexical( + child_lexical_left, + child_liveblocks_left as LiveDecoratorNode + ); + left++; + continue; + } else { + this.removeBindings(child_liveblocks_left); + children_liveblocks.delete(left); + + const node_liveblocks = + createStorageNodeFromLexicalNode(child_lexical_left); + children_liveblocks.insert(node_liveblocks, left); + this.createBinding(node_liveblocks, child_lexical_left); + + left++; + } + } + + const numOfChildrenToDelete = children_liveblocks.length - left - right; + if ( + numOfItems_liveblocks === 1 && + numOfItems_lexical === 0 && + children_liveblocks.get(0)?.get("kind") === "text" + ) { + const text_liveblocks = children_liveblocks.get(0) as LiveTextNode; + const content = text_liveblocks.get("content"); + if (content.length > 0) { + content.delete(0, content.length); + } + this.createBinding(text_liveblocks, []); + } else if (numOfChildrenToDelete > 0) { + for (let i = 0; i < numOfChildrenToDelete; i++) { + this.removeBindings(children_liveblocks.get(left)!); + children_liveblocks.delete(left); + } + } + + // LiveList has no batch insert (Yjs: `insert(left, ins)`). Insert each + // remaining child at its final index `i` so document order is preserved. + if (left + right < numOfItems_lexical) { + for (let i = left; i < numOfItems_lexical - right; i++) { + const child_lexical = children_lexical[i]; + const node_liveblocks = createStorageNodeFromLexicalNode(child_lexical); + children_liveblocks.insert(node_liveblocks, i); + this.createBinding(node_liveblocks, child_lexical); + } + } + } + + /** + * Syncs a Lexical decorator onto its bound storage node (Lexical → Storage). + * Decorators have no children channel — only `type` + optional `props`. + */ + public $reconcileDecoratorNodeFromLexical( + node_lexical: DecoratorNode, + node_liveblocks: LiveDecoratorNode + ): void { + node_lexical = node_lexical.getLatest(); + this.#binding.forward.set(node_liveblocks, node_lexical); + this.#binding.reverse.set(node_lexical.getKey(), node_liveblocks); + + const type_lexical = node_lexical.getType(); + if (node_liveblocks.get("type") !== type_lexical) { + node_liveblocks.set("type", type_lexical); + } + + const props_lexical = $getLexicalNodeProps(node_lexical); + const props_liveblocks = node_liveblocks.get("props"); + const props_liveblocks_json = + props_liveblocks !== undefined ? props_liveblocks.toJSON() : undefined; + if ( + !isEqual(props_lexical, props_liveblocks_json as JsonObject | undefined) + ) { + if (props_lexical === undefined) { + node_liveblocks.delete("props"); + } else if (!(props_liveblocks instanceof LiveMap)) { + node_liveblocks.set( + "props", + new LiveMap( + Object.entries(props_lexical).filter( + (entry): entry is [string, Json] => entry[1] !== undefined + ) + ) + ); + } else { + const keys = new Set(Object.keys(props_lexical)); + for (const key of props_liveblocks.keys()) { + if (!keys.has(key)) { + props_liveblocks.delete(key); + } + } + for (const [key, value] of Object.entries(props_lexical)) { + if (value === undefined) { + continue; + } + if (props_liveblocks.get(key) !== value) { + props_liveblocks.set(key, value); + } + } + } + } + } + + public $reconcileTextNodeFromLexical( + node_lexical: readonly TextNode[], + node_liveblocks: LiveTextNode + ): void { + // Invariant: empty LiveText ↔ no attached TextNodes (`[]`). Detached keys + // from a prior binding are not a live text slot — treat them as empty. + const target = node_lexical + .map((node) => $getNodeByKey(node.getKey())) + .filter( + (node): node is TextNode => + node !== null && $isTextNode(node) && node.isAttached() + ); + + if (target.length === 0) { + const content = node_liveblocks.get("content"); + if (content.length > 0) { + content.delete(0, content.length); + } + this.createBinding(node_liveblocks, []); + return; + } + + if (areTextNodesStructurallyEqual(node_liveblocks, target)) { + this.createBinding(node_liveblocks, target); + return; + } + + const content = node_liveblocks.get("content"); + const segments_liveblocks = content.toJSON(); + const segments_target = createSegmentsFromTextNodes(target); + + const plain_liveblocks = segments_liveblocks + .map((segment) => segment[0]) + .join(""); + const plain_target = target.map((node) => node.getTextContent()).join(""); + if (plain_liveblocks !== plain_target) { + let prefix = 0; + while ( + prefix < plain_liveblocks.length && + prefix < plain_target.length && + plain_liveblocks[prefix] === plain_target[prefix] + ) { + prefix++; + } + + let suffix = 0; + while ( + suffix < plain_liveblocks.length - prefix && + suffix < plain_target.length - prefix && + plain_liveblocks[plain_liveblocks.length - 1 - suffix] === + plain_target[plain_target.length - 1 - suffix] + ) { + suffix++; + } + + const removeLength = plain_liveblocks.length - prefix - suffix; + const insertText = plain_target.slice( + prefix, + plain_target.length - suffix + ); + if (removeLength > 0 || insertText.length > 0) { + content.replace( + prefix, + removeLength, + insertText, + insertText.length > 0 + ? getSegmentAttributesAtOffset(segments_target, prefix) + : undefined + ); + } + } + + let offset = 0; + for (const segment of segments_target) { + const [text, attributes] = segment; + const attributes_target: TextAttributes = + attributes !== undefined ? { ...attributes } : {}; + + const slice = getSegmentsInRange(content.toJSON(), { + rangeStart: offset, + rangeEnd: offset + text.length, + }); + + const patch = createLiveTextAttributesPatch(attributes_target, slice); + const matches = + slice.map((part) => part[0]).join("") === text && + slice.length === 1 && + Object.keys(patch).length === 0; + + if (!matches) { + content.format(offset, text.length, patch); + } + + offset += text.length; + } + + this.createBinding(node_liveblocks, target); + } + + /** + * Scores how well a candidate element pair's children overlap from the start + * and end. + */ + private $getChildDiffOverlap( + element_liveblocks: LiveElementNode, + element_lexical: ElementNode + ): { + numOfMatchingPrefix: number; + numOfMatchingSuffix: number; + numOfIdenticalChildren: number; + } { + const children_liveblocks = element_liveblocks.get("children"); + const children_lexical = $normalizeLexicalChildren(element_lexical); + + const numOfChildren_liveblocks = children_liveblocks.length; + const numOfChildren_lexical = children_lexical.length; + + const minCount = Math.min(numOfChildren_liveblocks, numOfChildren_lexical); + + let left = 0; + let right = 0; + let numOfIdenticalChildren = 0; + + for (; left < minCount; left++) { + const child_liveblocks = children_liveblocks.get(left); + const child_lexical = children_lexical[left]; + if (child_liveblocks === undefined) break; + + const kind_liveblocks = child_liveblocks.get("kind"); + + if (child_lexical instanceof Array) { + if (kind_liveblocks !== "text") break; + + const text_liveblocks = child_liveblocks as LiveTextNode; + const text_lexical = this.#binding.forward.get(text_liveblocks); + if ( + $isTextNodeList(text_lexical) && + areListsEqual(text_lexical, child_lexical) + ) { + numOfIdenticalChildren++; + } else if ( + !areTextNodesStructurallyEqual(text_liveblocks, child_lexical) + ) { + break; + } + } else if ($isElementNode(child_lexical)) { + if (kind_liveblocks !== "element") break; + + const element_lexical_mapped = this.#binding.forward.get( + child_liveblocks as LiveElementNode + ); + if ( + $isLexicalNode(element_lexical_mapped) && + $isElementNode(element_lexical_mapped) && + element_lexical_mapped === child_lexical + ) { + numOfIdenticalChildren++; + } else if ( + !areElementNodesStructurallyEqual( + child_liveblocks as LiveElementNode, + child_lexical + ) + ) { + break; + } + } else { + break; + } + } + + for (; left + right < minCount; right++) { + const child_liveblocks = children_liveblocks.get( + numOfChildren_liveblocks - right - 1 + ); + const child_lexical = children_lexical[numOfChildren_lexical - right - 1]; + if (child_liveblocks === undefined) break; + + const kind_liveblocks = child_liveblocks.get("kind"); + + if (child_lexical instanceof Array) { + if (kind_liveblocks !== "text") break; + + const text_liveblocks = child_liveblocks as LiveTextNode; + const text_lexical = this.#binding.forward.get(text_liveblocks); + if ( + $isTextNodeList(text_lexical) && + areListsEqual(text_lexical, child_lexical) + ) { + numOfIdenticalChildren++; + } else if ( + !areTextNodesStructurallyEqual(text_liveblocks, child_lexical) + ) { + break; + } + } else if ($isElementNode(child_lexical)) { + if (kind_liveblocks !== "element") break; + + const element_lexical_mapped = this.#binding.forward.get( + child_liveblocks as LiveElementNode + ); + if ( + $isLexicalNode(element_lexical_mapped) && + $isElementNode(element_lexical_mapped) && + element_lexical_mapped === child_lexical + ) { + numOfIdenticalChildren++; + } else if ( + !areElementNodesStructurallyEqual( + child_liveblocks as LiveElementNode, + child_lexical + ) + ) { + break; + } + } else { + break; + } + } + + return { + numOfMatchingPrefix: left, + numOfMatchingSuffix: right, + numOfIdenticalChildren, + }; + } + + public $applyRemoteUpdates(updates: readonly StorageUpdate[]) { + // Apply peer edits and local undo/redo replays. Skip our own live + // mutations: Lexical already reflects those. History updates use + // `origin: "local", via: "undo" | "redo"` (see UpdateSource). + updates = updates.filter((update) => { + const source = update.source; + return ( + source.origin === "remote" || + (source.origin === "local" && + (source.via === "undo" || source.via === "redo")) + ); + }); + if (updates.length === 0) { + return; + } + + // Snapshot Lexical nodes for deletes *before* dropping bindings. The + // LiveList in the update is already post-delete, so sibling/segment index + // math cannot recover the removed span — especially when a surviving + // neighbor is a multi-segment LiveText (bold/plain splits). Binding is + // the only reliable handle; clear it after we capture the nodes so a + // later LiveText update in the same batch cannot mutate a detached slot. + // + // Net-zero history batches (`[insert, delete]` of the same child) create + // the binding only during the insert below — prefer live bindings at + // delete-apply time, and use this snapshot as the delete-only fallback. + const deletedLexicalNodes = new Map(); + for (const update of updates) { + if (update.type !== "LiveList") { + continue; + } + + for (const change of update.updates) { + if ( + change.type === "delete" && + change.deletedItem instanceof LiveObject + ) { + const child = change.deletedItem as LiveStorageNode; + const nodes = this.$getBoundLexicalNodes(child); + if (nodes.length > 0) { + deletedLexicalNodes.set(child, nodes); + } + this.removeBindings(child); + } + } + } + + for (const update of updates) { + if (update.type === "LiveList") { + const parent_liveblocks = this.findParentForLiveList( + update.node as LiveList + ); + if (parent_liveblocks === null) { + continue; + } + + const parent_lexical = this.#binding.forward.get(parent_liveblocks); + if ( + !$isLexicalNode(parent_lexical) || + !$isElementNode(parent_lexical) + ) { + continue; + } + + for (const change of update.updates) { + if (change.type === "insert") { + if (!(change.item instanceof LiveObject)) { + continue; + } + + const child_liveblocks = change.item as LiveChildNode; + if ( + this.#binding.forward.get(child_liveblocks as LiveStorageNode) !== + undefined + ) { + continue; + } + + const children_liveblocks = parent_liveblocks.get("children"); + let index_lexical = 0; + for (let i = 0; i < change.index; i++) { + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + + index_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + + const parent = parent_lexical.getLatest(); + const kind = child_liveblocks.get("kind"); + + if (kind === "text") { + const nodes_lexical = $convertLiveTextNodeToLexicalNode( + child_liveblocks as LiveTextNode + ); + parent.splice(index_lexical, 0, nodes_lexical); + this.createBinding( + child_liveblocks as LiveTextNode, + nodes_lexical + ); + } else if (kind === "linebreak") { + const node_lexical = $createLineBreakNode(); + parent.splice(index_lexical, 0, [node_lexical]); + this.createBinding( + child_liveblocks as LiveLineBreakNode, + node_lexical + ); + } else if (kind === "element") { + const node_lexical = $convertLiveElementNodeToLexicalNode( + child_liveblocks as LiveElementNode + ); + parent.splice(index_lexical, 0, [node_lexical]); + this.createBinding( + child_liveblocks as LiveElementNode, + node_lexical + ); + } else if (kind === "decorator") { + const node_lexical = $convertLiveDecoratorNodeToLexicalNode( + child_liveblocks as LiveDecoratorNode + ); + parent.splice(index_lexical, 0, [node_lexical]); + this.createBinding( + child_liveblocks as LiveDecoratorNode, + node_lexical + ); + } else { + console.warn( + `Unsupported remote insert of storage kind "${String(kind)}".` + ); + } + } else if (change.type === "delete") { + if (!(change.deletedItem instanceof LiveObject)) { + continue; + } + + const child_liveblocks = change.deletedItem as LiveStorageNode; + // Prefer live binding: same-batch insert (e.g. history undo of + // insert-then-delete) may have just created it after the pre-pass + // cleared bindings. Fall back to the pre-pass snapshot for + // delete-only batches. + const liveNodes = this.$getBoundLexicalNodes(child_liveblocks); + const nodes = ( + liveNodes.length > 0 + ? liveNodes + : (deletedLexicalNodes.get(child_liveblocks) ?? []) + ).filter((node) => node.isAttached()); + if (nodes.length === 0) { + continue; + } + + nodes.sort( + (a, b) => a.getIndexWithinParent() - b.getIndexWithinParent() + ); + const parent = nodes[0].getParent(); + if (parent === null || !$isElementNode(parent)) { + continue; + } + + parent + .getLatest() + .splice(nodes[0].getIndexWithinParent(), nodes.length, []); + if (liveNodes.length > 0) { + this.removeBindings(child_liveblocks); + } + } else if (change.type === "move") { + if (!(change.item instanceof LiveObject)) { + continue; + } + + if (change.previousIndex === change.index) { + continue; + } + + const child_liveblocks = change.item as LiveChildNode; + const children_liveblocks = parent_liveblocks.get("children"); + + // LiveList is already in the post-move order. Reconstruct the + // Lexical splice index of the old position from previousIndex. + let from_lexical = 0; + if (change.previousIndex < change.index) { + // Moved forward: items before previousIndex are unchanged. + for (let i = 0; i < change.previousIndex; i++) { + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + + from_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + } else { + // Moved backward: old predecessors = [0, index) + [index+1, previousIndex]. + for (let i = 0; i <= change.previousIndex; i++) { + if (i === change.index) { + continue; + } + + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + + from_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + } + + const moveCount = + child_liveblocks.get("kind") === "text" + ? (child_liveblocks as LiveTextNode).get("content").toJSON() + .length + : 1; + + const parent = parent_lexical.getLatest(); + const movedNodes = parent + .getChildren() + .slice(from_lexical, from_lexical + moveCount); + parent.splice(from_lexical, moveCount, []); + + let index_lexical = 0; + for (let i = 0; i < change.index; i++) { + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + + index_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + + parent.splice(index_lexical, 0, movedNodes); + } else if (change.type === "set") { + if (!(change.item instanceof LiveObject)) { + continue; + } + + const child_liveblocks = change.item as LiveChildNode; + if ( + this.#binding.forward.get(child_liveblocks as LiveStorageNode) !== + undefined + ) { + continue; + } + + const children_liveblocks = parent_liveblocks.get("children"); + let index_lexical = 0; + for (let i = 0; i < change.index; i++) { + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + + index_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + + // LiveList already holds the new item at change.index. Lexical still + // has the old slot — read its span from normalized children, and drop + // the old storage binding (set deltas do not include deletedItem). + const parent = parent_lexical.getLatest(); + const children_lexical = $normalizeLexicalChildren(parent); + const old_slot = children_lexical[change.index]; + let deleteCount = 0; + if ($isTextNodeList(old_slot)) { + const old_storage = this.#binding.reverse.get( + old_slot[0].getKey() + ); + if (old_storage !== undefined) { + this.removeBindings(old_storage); + } + deleteCount = old_slot.length; + } else if ($isLexicalNode(old_slot)) { + const old_storage = this.#binding.reverse.get(old_slot.getKey()); + if (old_storage !== undefined) { + this.removeBindings(old_storage); + } + deleteCount = 1; + } + + const kind = child_liveblocks.get("kind"); + if (kind === "text") { + const nodes_lexical = $convertLiveTextNodeToLexicalNode( + child_liveblocks as LiveTextNode + ); + parent.splice(index_lexical, deleteCount, nodes_lexical); + this.createBinding( + child_liveblocks as LiveTextNode, + nodes_lexical + ); + } else if (kind === "linebreak") { + const node_lexical = $createLineBreakNode(); + parent.splice(index_lexical, deleteCount, [node_lexical]); + this.createBinding( + child_liveblocks as LiveLineBreakNode, + node_lexical + ); + } else if (kind === "element") { + const node_lexical = $convertLiveElementNodeToLexicalNode( + child_liveblocks as LiveElementNode + ); + parent.splice(index_lexical, deleteCount, [node_lexical]); + this.createBinding( + child_liveblocks as LiveElementNode, + node_lexical + ); + } else if (kind === "decorator") { + const node_lexical = $convertLiveDecoratorNodeToLexicalNode( + child_liveblocks as LiveDecoratorNode + ); + parent.splice(index_lexical, deleteCount, [node_lexical]); + this.createBinding( + child_liveblocks as LiveDecoratorNode, + node_lexical + ); + } else { + console.warn( + `Unsupported remote set of storage kind "${String(kind)}".` + ); + } + } + } + continue; + } + + if (update.type === "LiveText") { + // LiveTextUpdate.node is the inner LiveText; find its LiveTextNode wrapper. + const text_liveblocks = find_liveblocksNode(this.root, (node) => { + if (node.get("kind") !== "text") { + return false; + } + return (node as LiveTextNode).get("content") === update.node; + }) as LiveTextNode | null; + if (text_liveblocks === null) { + continue; + } + + const text_lexical = this.#binding.forward.get(text_liveblocks); + if ($isTextNodeList(text_lexical)) { + this.$reconcileTextNodeFromLiveblocks(text_lexical, text_liveblocks); + continue; + } + + // Unmapped LiveText — insert (or bind empty) at the storage child's + // Lexical span index under its parent. + this.$insertLexicalTextFromStorage(text_liveblocks); + } + + if (update.type === "LiveObject") { + const node_liveblocks = update.node as LiveStorageNode; + const kind = node_liveblocks.get("kind"); + if (kind !== "element" && kind !== "decorator") { + continue; + } + + const keysChanged = update.updates; + if (!("type" in keysChanged) && !("props" in keysChanged)) { + continue; + } + + const node_lexical = this.#binding.forward.get(node_liveblocks); + if (!$isLexicalNode(node_lexical)) { + continue; + } + + if (kind === "element" && $isElementNode(node_lexical)) { + let element_lexical = node_lexical.getLatest(); + const type_liveblocks = (node_liveblocks as LiveElementNode).get( + "type" + ); + + if ( + "type" in keysChanged && + element_lexical.getType() !== type_liveblocks + ) { + const info = $getEditor()._nodes.get(type_liveblocks); + if (info === undefined) { + console.warn( + `Unsupported remote type change to "${type_liveblocks}".` + ); + continue; + } + + const next_lexical = new info.klass(); + if (!$isElementNode(next_lexical)) { + console.warn( + `Remote type "${type_liveblocks}" is not an ElementNode.` + ); + continue; + } + + next_lexical.append(...element_lexical.getChildren()); + element_lexical.replace(next_lexical); + element_lexical = next_lexical.getLatest(); + this.createBinding( + node_liveblocks as LiveElementNode, + element_lexical + ); + } + + if ("type" in keysChanged || "props" in keysChanged) { + const props_liveblocks = (node_liveblocks as LiveElementNode).get( + "props" + ); + $setLexicalNodeProps( + element_lexical, + props_liveblocks !== undefined + ? (props_liveblocks.toJSON() as JsonObject) + : undefined + ); + } + } else if (kind === "decorator" && $isDecoratorNode(node_lexical)) { + let decorator_lexical = node_lexical.getLatest(); + const type_liveblocks = (node_liveblocks as LiveDecoratorNode).get( + "type" + ); + + if ( + "type" in keysChanged && + decorator_lexical.getType() !== type_liveblocks + ) { + const info = $getEditor()._nodes.get(type_liveblocks); + if (info === undefined) { + console.warn( + `Unsupported remote type change to "${type_liveblocks}".` + ); + continue; + } + + const next_lexical = new info.klass(); + if (!$isDecoratorNode(next_lexical)) { + console.warn( + `Remote type "${type_liveblocks}" is not a DecoratorNode.` + ); + continue; + } + + decorator_lexical.replace(next_lexical); + decorator_lexical = next_lexical.getLatest(); + this.createBinding( + node_liveblocks as LiveDecoratorNode, + decorator_lexical + ); + } + + if ("type" in keysChanged || "props" in keysChanged) { + const props_liveblocks = (node_liveblocks as LiveDecoratorNode).get( + "props" + ); + $setLexicalNodeProps( + decorator_lexical, + props_liveblocks !== undefined + ? (props_liveblocks.toJSON() as JsonObject) + : undefined + ); + } + } + continue; + } + + if (update.type === "LiveMap") { + // Granular props edits land on the element's/decorator's props LiveMap, + // not the LiveObject itself (after the map has been attached). + const host_liveblocks = find_liveblocksNode(this.root, (node) => { + if ( + node.get("kind") !== "element" && + node.get("kind") !== "decorator" + ) { + return false; + } + return (node as LiveRootChildNode).get("props") === update.node; + }) as LiveRootChildNode | null; + if (host_liveblocks === null) { + continue; + } + + const node_lexical = this.#binding.forward.get(host_liveblocks); + if ( + !$isLexicalNode(node_lexical) || + (!$isElementNode(node_lexical) && !$isDecoratorNode(node_lexical)) + ) { + continue; + } + + const props_liveblocks = host_liveblocks.get("props"); + $setLexicalNodeProps( + node_lexical.getLatest(), + props_liveblocks !== undefined + ? (props_liveblocks.toJSON() as JsonObject) + : undefined + ); + } + } + } + + /** + * Reconciles a coalesced storage text child into Lexical (Storage → Lexical). + * Inverse of `$reconcileTextNodeFromLexical`. + * + * Empty invariant (same as Lexical → Storage / Yjs XmlText): + * LiveText [] ↔ no attached TextNodes (`[]`) + * Non-empty LiveText materializes 1..N TextNodes; empty never invents a + * placeholder `TextNode ""`. + * + * When structure already matches, only the binding is refreshed. Otherwise: + * 1. Empty storage → remove any leftover TextNodes, bind `[]` + * 2. Empty Lexical slot + storage content → insert converted TextNodes + * 3. Plain-text diff via prefix/suffix + `spliceText` (single TextNode) + * 4. Per-segment format sync when segment count matches TextNode count + * 5. Replace the Lexical text span when coalescing diverges + */ + public $reconcileTextNodeFromLiveblocks( + node_lexical: readonly TextNode[], + node_liveblocks: LiveTextNode + ): void { + const target = node_lexical + .map((node) => $getNodeByKey(node.getKey())) + .filter( + (node): node is TextNode => + node !== null && $isTextNode(node) && node.isAttached() + ); + + const segments_liveblocks = node_liveblocks.get("content").toJSON(); + + if (segments_liveblocks.length === 0) { + if (target.length > 0) { + const parent = target[0].getParent(); + if (parent !== null && $isElementNode(parent)) { + parent + .getLatest() + .splice(target[0].getIndexWithinParent(), target.length, []); + } + } + this.createBinding(node_liveblocks, []); + return; + } + + if (target.length === 0) { + this.$insertLexicalTextFromStorage(node_liveblocks); + return; + } + + if (areTextNodesStructurallyEqual(node_liveblocks, target)) { + this.createBinding(node_liveblocks, target); + return; + } + + const plain_liveblocks = segments_liveblocks + .map((segment) => segment[0]) + .join(""); + const plain_target = target.map((node) => node.getTextContent()).join(""); + + if (plain_liveblocks !== plain_target) { + if (target.length === 1) { + const node = target[0].getWritable(); + const current = node.getTextContent(); + + let prefix = 0; + while ( + prefix < current.length && + prefix < plain_liveblocks.length && + current[prefix] === plain_liveblocks[prefix] + ) { + prefix++; + } + + let suffix = 0; + while ( + suffix < current.length - prefix && + suffix < plain_liveblocks.length - prefix && + current[current.length - 1 - suffix] === + plain_liveblocks[plain_liveblocks.length - 1 - suffix] + ) { + suffix++; + } + + const removeLength = current.length - prefix - suffix; + const insertText = plain_liveblocks.slice( + prefix, + plain_liveblocks.length - suffix + ); + + if (removeLength > 0 || insertText.length > 0) { + node.spliceText(prefix, removeLength, insertText); + } + } else { + this.$replaceLexicalTextSlot(target, node_liveblocks); + return; + } + } + + const refreshed = target + .map((node) => $getNodeByKey(node.getKey())) + .filter( + (node): node is TextNode => + node !== null && $isTextNode(node) && node.isAttached() + ); + + if (segments_liveblocks.length !== refreshed.length) { + this.$replaceLexicalTextSlot(refreshed, node_liveblocks); + return; + } + + for (let i = 0; i < refreshed.length; i++) { + const segment = segments_liveblocks[i]; + const attributes = + segment.length > 1 ? (segment[1] as TextAttributes) : {}; + const segmentType = + typeof attributes.type === "string" + ? attributes.type + : (TEXT_ATTRIBUTE_DEFAULTS.type as string); + if (refreshed[i].getType() !== segmentType) { + this.$replaceLexicalTextSlot(refreshed, node_liveblocks); + return; + } + } + + for (let i = 0; i < refreshed.length; i++) { + const segment = segments_liveblocks[i]; + const attributes = + segment.length > 1 ? (segment[1] as TextAttributes) : undefined; + refreshed[i] + .getWritable() + .updateFromJSON( + createSerializedTextNodeFromLiveTextSegment(segment[0], attributes) + ); + } + + const rebound = refreshed + .map((node) => $getNodeByKey(node.getKey())) + .filter( + (node): node is TextNode => + node !== null && $isTextNode(node) && node.isAttached() + ); + this.createBinding(node_liveblocks, rebound); + } + + /** + * Replaces an existing Lexical text span with TextNodes converted from + * storage. `node_lexical` must be non-empty attached nodes with a parent. + */ + private $replaceLexicalTextSlot( + node_lexical: readonly TextNode[], + node_liveblocks: LiveTextNode + ): void { + const parent = node_lexical[0].getParent(); + if (parent === null || !$isElementNode(parent)) { + return; + } + + const insertIndex = node_lexical[0].getIndexWithinParent(); + const nodes_lexical = $convertLiveTextNodeToLexicalNode(node_liveblocks); + parent.getLatest().splice(insertIndex, node_lexical.length, nodes_lexical); + this.createBinding(node_liveblocks, nodes_lexical); + } + + /** + * Empty → content transition: insert TextNodes for a storage text child that + * currently has no attached Lexical text nodes (`[]` binding). + */ + private $insertLexicalTextFromStorage(node_liveblocks: LiveTextNode): void { + const parent_liveblocks = this.findParent_liveblocks(node_liveblocks); + if (parent_liveblocks === null) { + return; + } + + const parent_lexical = this.#binding.forward.get(parent_liveblocks); + if (!$isLexicalNode(parent_lexical) || !$isElementNode(parent_lexical)) { + return; + } + + const children_liveblocks = parent_liveblocks.get("children"); + const index_liveblocks = children_liveblocks.indexOf( + node_liveblocks as never + ); + if (index_liveblocks === -1) { + return; + } + + let index_lexical = 0; + for (let i = 0; i < index_liveblocks; i++) { + const sibling = children_liveblocks.get(i); + if (sibling === undefined) { + break; + } + index_lexical += + sibling.get("kind") === "text" + ? (sibling as LiveTextNode).get("content").toJSON().length + : 1; + } + + const nodes_lexical = $convertLiveTextNodeToLexicalNode(node_liveblocks); + if (nodes_lexical.length === 0) { + this.createBinding(node_liveblocks, []); + return; + } + + parent_lexical.getLatest().splice(index_lexical, 0, nodes_lexical); + this.createBinding(node_liveblocks, nodes_lexical); + } + + /** + * Lexical nodes currently bound to a storage child. Text children return the + * coalesced TextNode[] span; elements/decorators/linebreaks return one node. + */ + private $getBoundLexicalNodes(node: LiveStorageNode): LexicalNode[] { + const bound = this.#binding.forward.get(node); + if (bound === undefined) { + return []; + } + + if (bound instanceof Array) { + return bound.filter( + (child) => $getNodeByKey(child.getKey()) !== null && child.isAttached() + ); + } + + const latest = $getNodeByKey(bound.getKey()); + if (latest === null || !latest.isAttached()) { + return []; + } + return [latest]; + } + + /** + * @internal + * Recursively build binding between a storage element and its matching Lexical node. + */ + public createBinding( + node_liveblocks: LiveTextNode, + node_lexical: readonly TextNode[] + ): void; + public createBinding( + node_liveblocks: LiveObject< + LiveElementShape | LiveLineBreakShape | LiveDecoratorShape + >, + node_lexical: ElementNode | LineBreakNode | DecoratorNode + ): void; + public createBinding( + node_liveblocks: LiveChildNode, + node_lexical: + | readonly TextNode[] + | ElementNode + | LineBreakNode + | DecoratorNode + ): void; + public createBinding( + node_liveblocks: + | LiveTextNode + | LiveObject, + node_lexical: + | readonly TextNode[] + | ElementNode + | LineBreakNode + | DecoratorNode + ) { + if (node_lexical instanceof Array) { + // Drop reverse entries from a previous TextNode[] binding so empty `[]` + // and rebinds do not leave stale keys pointing at this LiveText. + const previous = this.#binding.forward.get(node_liveblocks); + if (previous instanceof Array) { + for (const child of previous) { + if (this.#binding.reverse.get(child.getKey()) === node_liveblocks) { + this.#binding.reverse.delete(child.getKey()); + } + } + } + + this.#binding.forward.set(node_liveblocks, node_lexical); + for (const node of node_lexical) { + this.#binding.reverse.set(node.getKey(), node_liveblocks); + } + } else { + if ($isElementNode(node_lexical)) { + this.#binding.forward.set(node_liveblocks, node_lexical); + this.#binding.reverse.set(node_lexical.getKey(), node_liveblocks); + + const children_lexical = node_lexical.getChildren(); + let index = 0; + for (const child of (node_liveblocks as LiveElementNode).get( + "children" + )) { + const kind = child.get("kind"); + + switch (kind) { + case "text": { + // Empty LiveText ↔ []; non-empty ↔ one TextNode per segment. + const node = child as LiveTextNode; + const count = node.get("content").toJSON().length; + const nodes = children_lexical.slice( + index, + index + count + ) as TextNode[]; + this.createBinding(node, nodes); + index += count; + break; + } + case "linebreak": { + const liveLineBreak = child as LiveLineBreakNode; + const lineBreakNode = children_lexical[index] as LineBreakNode; + this.#binding.forward.set(liveLineBreak, lineBreakNode); + this.#binding.reverse.set(lineBreakNode.getKey(), liveLineBreak); + index++; + break; + } + case "element": { + this.createBinding( + child as LiveElementNode, + children_lexical[index] as ElementNode + ); + index++; + break; + } + case "decorator": { + this.createBinding( + child as LiveDecoratorNode, + children_lexical[index] as DecoratorNode + ); + index++; + break; + } + default: + throw new Error(`Unsupported node of kind "${String(kind)}"`); + } + } + } else if ($isLineBreakNode(node_lexical)) { + this.#binding.forward.set(node_liveblocks, node_lexical); + this.#binding.reverse.set(node_lexical.getKey(), node_liveblocks); + } else if ($isDecoratorNode(node_lexical)) { + this.#binding.forward.set(node_liveblocks, node_lexical); + this.#binding.reverse.set(node_lexical.getKey(), node_liveblocks); + } + } + } + + private removeBindings(node: LiveStorageNode): void { + const node_lexical = this.#binding.forward.get(node); + if (node_lexical !== undefined) { + // Only delete reverse entries this storage node still owns — a Lexical + // node may have been rebound to a fresh storage child already (e.g. + // after a reparent), and that new binding must survive. + if (node_lexical instanceof Array) { + for (const child of node_lexical) { + if (this.#binding.reverse.get(child.getKey()) === node) { + this.#binding.reverse.delete(child.getKey()); + } + } + } else { + if (this.#binding.reverse.get(node_lexical.getKey()) === node) { + this.#binding.reverse.delete(node_lexical.getKey()); + } + } + this.#binding.forward.delete(node); + } + + const kind = node.get("kind"); + if (kind === "element") { + for (const child of (node as LiveElementNode).get("children")) { + this.removeBindings(child); + } + } else if (kind === "root") { + for (const child of (node as LiveRootNode).get("children")) { + this.removeBindings(child); + } + } + } + + /** + * Finds the storage host whose 'children' list property is the given 'LiveList'. + * Performs a depth-first search, starting from the root, only descending into + * element nodes' children, and returns the first matching parent whose children + * reference equals the given list. If none is found, it returns null. + * + * @example + * + * Storage: + * root.children (LiveList) ← update.node + * ├── paragraph P0 + * └── paragraph P1 (newly inserted) + * + * findParentForLiveList(root.children) → root + * + * @example + * + * Storage: + * root + * └── paragraph P1 + * children (LiveList) ← update.node + * ├── text + * └── linebreak + * + * findParentForLiveList(P1.children) → paragraph P1 + */ + private findParentForLiveList( + children: LiveList + ): LiveObject | null { + const parentsToSearch: LiveObject[] = [ + this.root, + ]; + + while (parentsToSearch.length > 0) { + const candidate = parentsToSearch.pop()!; + if (candidate.get("children") === children) { + return candidate; + } + + for (const child of candidate.get("children")) { + if (child.get("kind") === "element") { + parentsToSearch.push(child as LiveElementNode); + } + } + } + + return null; + } + + private findParent_liveblocks( + node: LiveChildNode + ): LiveObject | null; + private findParent_liveblocks( + node: LexicalNode + ): LiveObject | null; + private findParent_liveblocks( + node: LiveChildNode | LexicalNode + ): LiveObject | null { + if (node instanceof LiveObject) { + return this.findStorageParent(this.root, node); + } else { + const parent_lexical = node.getParent(); + if (parent_lexical === null) { + return null; + } + if ($isRootNode(parent_lexical)) { + return this.root; + } + if ($isElementNode(parent_lexical)) { + const parent_liveblocks = this.#binding.reverse.get( + parent_lexical.getKey() + ); + if (parent_liveblocks !== undefined) { + return parent_liveblocks as LiveObject< + LiveRootShape | LiveElementShape + >; + } + } + return this.findParent_liveblocks(parent_lexical); + } + } + + private findStorageParent( + parent: LiveObject, + target: LiveChildNode + ): LiveObject | null { + const children = parent.get("children"); + if (children.indexOf(target as never) !== -1) { + return parent; + } + + for (const sibling of children) { + if (sibling.get("kind") !== "element") continue; + + const element = sibling as LiveObject; + const nested = this.findStorageParent(element, target); + if (nested !== null) { + return nested; + } + } + return null; + } +} + +/** + * Normalized Lexical child slots for binding-aware index matching. + * + * LiveList indices count storage children; Lexical may have more raw children + * because one storage text child spans multiple TextNodes. Consecutive TextNodes + * coalesce into one slot only when `reverse` maps them to the same storage node. + * + * @example One storage text child, two TextNodes → one slot + * + * Storage paragraph P1 Lexical paragraph p1 + * └── [0] text (LiveText) ├── [t1 "Hello " bold, t2 "!"] ← slot 0 + * [["Hello ",{bold}],["!"]] └── (raw index 0–1 → normalized 0) + * + * @example Element children → one slot each + * + * Lexical (normalized): [ [t1,t2], LineBreak, t3 ] + * LiveList indices: 0 1 2 + */ +function $normalizeLexicalChildren( + node: ElementNode +): Array< + readonly TextNode[] | ElementNode | LineBreakNode | DecoratorNode +> { + const children = node.getChildren(); + const slots: Array< + TextNode[] | ElementNode | LineBreakNode | DecoratorNode + > = []; + + for (let i = 0; i < children.length; ) { + const child = children[i]; + if ($isTextNode(child)) { + const nodes: TextNode[] = [child]; + i++; + while (i < children.length) { + const node = children[i]; + if (!$isTextNode(node)) break; + + nodes.push(node); + i++; + } + slots.push(nodes); + } else if ($isElementNode(child)) { + slots.push(child.getLatest()); + i++; + } else if ($isLineBreakNode(child)) { + slots.push(child.getLatest()); + i++; + } else if ($isDecoratorNode(child)) { + slots.push(child.getLatest()); + i++; + } else { + console.warn( + `Unsupported lexical node type "${child.getType()}" for storage materialization.` + ); + i++; + } + } + + return slots; +} + +function $isLexicalNode( + node: LexicalNode | readonly TextNode[] | undefined +): node is LexicalNode { + if (node === undefined) return false; + if (node instanceof Array) return false; + return true; +} + +function $isTextNodeList( + node: LexicalNode | readonly TextNode[] | undefined +): node is readonly TextNode[] { + if (node === undefined) return false; + if (!(node instanceof Array)) return false; + return true; +} + +function areListsEqual(a: readonly T[], b: readonly T[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function isEqual( + a: JsonObject | undefined, + b: JsonObject | undefined +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + const leftKeys = Object.keys(a); + const rightKeys = Object.keys(b); + if (leftKeys.length !== rightKeys.length) return false; + for (const key of leftKeys) { + if (a[key] !== b[key]) return false; + } + return true; +} + +/** + * Materializes a Liveblocks storage child from Lexical content (Lexical → Live). + * + * Dispatches by input shape: + * - `TextNode[]` → one `LiveTextNode` (coalesced segments) + * - linebreak → `LiveLineBreakNode` + * - decorator → `LiveDecoratorNode` (`type` + optional `props`) + * - element → recurse into raw Lexical children (consecutive TextNodes coalesce) + * + * @example Coalesced text — two Lexical spans, one LiveText child + * + * Lexical (normalized slot): Storage (return value): + * [TextNode "Hello " (bold), text + * TextNode "world"] └── LiveText segments: + * ["Hello ", {bold}] + * ["world"] + * + * @example New paragraph insert at root + * + * Lexical: Storage: + * Paragraph p2 (unbound) element (paragraph) + * └── TextNode "Hi" └── text → [["Hi"]] + * + * @example Paragraph with line break + * + * Lexical: Storage: + * Paragraph element (paragraph) + * ├── TextNode "Hi" ├── text → [["Hi"]] + * └── LineBreak └── linebreak + * + * @example Nested element + * + * Lexical: Storage: + * Quote element (quote) + * └── Paragraph └── element (paragraph) + * └── TextNode "Hi" └── text → [["Hi"]] + * + * @param node - A normalized text slot (`TextNode[]`) or a single Lexical node. + * @returns A new storage child ready to insert into a parent `children` LiveList. + * @throws {Error} When the Lexical node type is not supported for materialization. + */ + +export function createStorageNodeFromLexicalNode( + node: readonly TextNode[] +): LiveTextNode; +export function createStorageNodeFromLexicalNode( + node: ElementNode +): LiveElementNode; +export function createStorageNodeFromLexicalNode( + node: LineBreakNode +): LiveLineBreakNode; +export function createStorageNodeFromLexicalNode( + node: DecoratorNode +): LiveDecoratorNode; +export function createStorageNodeFromLexicalNode( + node: + | readonly TextNode[] + | ElementNode + | LineBreakNode + | DecoratorNode +): LiveChildNode; +export function createStorageNodeFromLexicalNode( + node: LexicalNode | readonly TextNode[] +): LiveElementNode | LiveTextNode | LiveLineBreakNode | LiveDecoratorNode { + if (node instanceof Array) { + const node_liveblocks = new LiveObject({ + kind: "text", + type: "text", + version: 1, + content: new LiveText(), + }); + + const text = node_liveblocks.get("content"); + const segments = createSegmentsFromTextNodes( + node.map((n) => n.getLatest()) + ); + let offset = 0; + for (const segment of segments) { + const [str, attributes] = segment; + if (str.length === 0) continue; + text.insert( + offset, + str, + attributes !== undefined ? attributes : undefined + ); + offset += str.length; + } + + return node_liveblocks; + } + + if ($isElementNode(node)) { + const children_liveblocks: LiveChildNode[] = []; + const children_lexical = node.getChildren(); + + for (let i = 0; i < children_lexical.length; i++) { + const child = children_lexical[i]; + if ($isTextNode(child)) { + const textNodes: TextNode[] = []; + for ( + let textNode = child; + i < children_lexical.length && $isTextNode(textNode); + textNode = children_lexical[++i] as TextNode + ) { + textNodes.push(textNode.getLatest()); + } + i--; + children_liveblocks.push(createStorageNodeFromLexicalNode(textNodes)); + } else if ($isElementNode(child)) { + children_liveblocks.push( + createStorageNodeFromLexicalNode(child.getLatest()) + ); + } else if ($isLineBreakNode(child)) { + children_liveblocks.push(createStorageNodeFromLexicalNode(child)); + } else if ($isDecoratorNode(child)) { + children_liveblocks.push( + createStorageNodeFromLexicalNode(child.getLatest()) + ); + } else { + throw new Error( + `Unsupported lexical node type "${child.getType()}" for storage materialization.` + ); + } + } + + const props_lexical = $getLexicalNodeProps(node); + + return new LiveObject({ + kind: "element", + type: node.getType(), + version: 1, + children: new LiveList(children_liveblocks), + ...(props_lexical !== undefined + ? { + props: new LiveMap( + Object.entries(props_lexical).filter( + (entry): entry is [string, Json] => entry[1] !== undefined + ) + ), + } + : {}), + }); + } + + if ($isLineBreakNode(node)) { + return new LiveObject({ + kind: "linebreak", + type: "linebreak", + version: 1, + }); + } + + if ($isDecoratorNode(node)) { + const props_lexical = $getLexicalNodeProps(node); + + return new LiveObject({ + kind: "decorator", + type: node.getType(), + version: 1, + ...(props_lexical !== undefined + ? { + props: new LiveMap( + Object.entries(props_lexical).filter( + (entry): entry is [string, Json] => entry[1] !== undefined + ) + ), + } + : {}), + }); + } + + throw new Error( + `Unsupported lexical node type "${node.getType()}" for storage materialization.` + ); +} + +/** + * Builds a Lexical element from a storage element node, recursing into its LiveList children + * (bootstrap: Live → Lexical). Used when loading the document on first connect. + * + * Dispatches each child by `kind`: + * - `text` → spread `$convertLiveTextNodeToLexicalNode` (1 storage child → N TextNodes) + * - `linebreak`→ `$createLineBreakNode()` + * - `element` → recurse + * - `decorator`→ `$convertLiveDecoratorNodeToLexicalNode` + * + * The Lexical class is resolved from `node.get("type")` (e.g. `"paragraph"`, `"heading"`). + * + * @example Paragraph with coalesced text + * + * Storage: Lexical (return value): + * element (paragraph) Paragraph + * └── text → [["Hello ", {bold}], ├── TextNode "Hello " (bold) + * ["world"]] └── TextNode "world" + * + * @example Nested quote → paragraph + * + * Storage: Lexical: + * element (quote) Quote + * └── element (paragraph) └── Paragraph + * └── text → [["Hi"]] └── TextNode "Hi" + * + * @example Paragraph with line break + * + * Storage: Lexical: + * element (paragraph) Paragraph + * ├── text → [["Hi"]] ├── TextNode "Hi" + * └── linebreak └── LineBreak + * + * @example Paragraph with decorator + * + * Storage: Lexical: + * element (paragraph) Paragraph + * ├── text → [["Hi"]] ├── TextNode "Hi" + * └── decorator (image, props) └── ImageNode + */ +function $convertLiveElementNodeToLexicalNode( + node: LiveElementNode +): ElementNode { + const editor = $getEditor(); + const type = node.get("type"); + const info = editor._nodes.get(type); + if (info === undefined) { + throw new Error( + `Node of type "${type}" is not registered. Please ensure that the node has been registered with the editor.` + ); + } + const node_lexical = new info.klass(); + if (!$isElementNode(node_lexical)) { + throw new Error(`Node of type "${type}" is not an ElementNode.`); + } + + const children: LexicalNode[] = []; + const children_liveblocks = node.get("children"); + if (children_liveblocks === undefined) { + return node_lexical.getLatest(); + } + for (const child of children_liveblocks) { + const kind = child.get("kind"); + switch (kind) { + case "text": + children.push( + ...$convertLiveTextNodeToLexicalNode(child as LiveTextNode) + ); + break; + case "linebreak": + children.push($createLineBreakNode()); + break; + case "element": + children.push( + $convertLiveElementNodeToLexicalNode(child as LiveElementNode) + ); + break; + case "decorator": + children.push( + $convertLiveDecoratorNodeToLexicalNode(child as LiveDecoratorNode) + ); + break; + default: + throw new Error(`Unsupported live node kind "${String(kind)}"`); + } + } + + node_lexical.append(...children); + + const props_liveblocks = node.get("props"); + if (props_liveblocks !== undefined) { + $setLexicalNodeProps(node_lexical, props_liveblocks.toJSON() as JsonObject); + } + + return node_lexical.getLatest(); +} + +/** + * Builds a Lexical decorator from a storage decorator node (bootstrap: Live → Lexical). + * + * Decorators have no children channel — only `type` + optional `props`. + */ +function $convertLiveDecoratorNodeToLexicalNode( + node: LiveDecoratorNode +): DecoratorNode { + const editor = $getEditor(); + const type = node.get("type"); + const info = editor._nodes.get(type); + if (info === undefined) { + throw new Error( + `Node of type "${type}" is not registered. Please ensure that the node has been registered with the editor.` + ); + } + const node_lexical = new info.klass(); + if (!$isDecoratorNode(node_lexical)) { + throw new Error(`Node of type "${type}" is not a DecoratorNode.`); + } + + const props_liveblocks = node.get("props"); + if (props_liveblocks !== undefined) { + $setLexicalNodeProps(node_lexical, props_liveblocks.toJSON() as JsonObject); + } + + return node_lexical.getLatest(); +} + +/** + * Builds one or more Lexical TextNodes from a single storage text child (bootstrap: Live → Lexical). + * + * Storage coalesces sibling spans into one LiveText; this function splits segments back into + * separate TextNodes, applying inline format flags from each segment's attributes. + * + * @example Multiple segments → multiple TextNodes + * + * Storage (one text child): Lexical (return value): + * text → LiveText segments: [ + * ["Hello ", {bold: true}] TextNode "Hello " (bold), + * ["world"] TextNode "world" + * ] + * + * @example Empty LiveText → no Lexical TextNodes + * + * Storage: Lexical: + * text → LiveText: [] [] + * + * @example Single unformatted segment + * + * Storage: Lexical: + * text → LiveText: [["hello"]] [TextNode "hello"] + */ +function $convertLiveTextNodeToLexicalNode(node: LiveTextNode): TextNode[] { + const segments = node.get("content").toJSON(); + if (segments.length === 0) { + return []; + } + + const nodes: TextNode[] = []; + for (const segment of segments) { + const attributes = segment.length > 1 ? segment[1] : undefined; + const type = + attributes !== undefined && typeof attributes.type === "string" + ? attributes.type + : (TEXT_ATTRIBUTE_DEFAULTS.type as string); + const info = $getEditor()._nodes.get(type); + if (info === undefined) { + throw new Error( + `Node of type "${type}" is not registered. Please ensure that the node has been registered with the editor.` + ); + } + + const node = new info.klass(); + if (!$isTextNode(node)) { + throw new Error(`Node of type "${type}" is not a TextNode.`); + } + + nodes.push( + node + .updateFromJSON( + createSerializedTextNodeFromLiveTextSegment(segment[0], attributes) + ) + .getLatest() + ); + } + return nodes; +} + +export function find_liveblocksNode( + node: LiveStorageNode, + predicate: (node: LiveStorageNode) => boolean +): LiveStorageNode | null { + if (predicate(node)) { + return node; + } + + const kind = node.get("kind"); + if (kind === "root" || kind === "element") { + for (const child of ( + node as LiveObject<{ + kind: "root" | "element"; + children: LiveList; + }> + ).get("children")) { + const found = find_liveblocksNode(child, predicate); + if (found !== null) { + return found; + } + } + } + + return null; +} + +/** + * Compares a coalesced LiveText node against one or more sibling Lexical TextNodes. + * + * Lexical stores each formatted span as its own TextNode; storage holds sibling spans + * as segments inside a single LiveText. This function checks that segment strings and + * attributes (readable marks, mode/detail/style, subclass `type`, and other + * public exportJSON fields) match — not Lexical object identity. + * + * @example Returns `true` — two Lexical spans, one LiveText child + * + * Lexical (siblings under Paragraph): Storage (one text child): + * TextNode "Hello " (bold) text → LiveText segments: + * TextNode "world" ["Hello ", {bold}] + * ["world"] + * + * @example Returns `true` — empty text slot + * + * Lexical: Storage: + * (no TextNodes) text → LiveText: [] + * + * Both represent an empty text run. + * + * @example Returns `false` — same spans, different string + * + * Lexical: Storage: + * TextNode "Hello " (bold) text → [["Hello ", {bold}], ["world"]] + * TextNode "world!" ↑ stale + * + * @example Returns `false` — same string, different format + * + * Lexical: Storage: + * TextNode "Hello" (bold) text → [["Hello"]] (no attributes) + * TextNode "world" + * + * @example Returns `false` — segment count mismatch + * + * Lexical: Storage: + * TextNode "Hello world" text → [["Hello ", {bold}], ["world"]] + * (single unformatted span) + */ +export function areTextNodesStructurallyEqual( + text_liveblocks: LiveTextNode, + text_lexical: readonly TextNode[] +): boolean { + const nodes_lexical = text_lexical.map((node) => node.getLatest()); + const segments_liveblocks = text_liveblocks.get("content").toJSON(); + + // Empty LiveText ↔ no TextNodes. A lone empty TextNode "" is not the empty + // slot (createSegmentsFromTextNodes collapses it to []), so check this first. + if (segments_liveblocks.length === 0) { + return nodes_lexical.length === 0; + } + if (nodes_lexical.length === 0) { + return false; + } + + const segments_lexical = createSegmentsFromTextNodes(nodes_lexical); + + if (segments_liveblocks.length !== segments_lexical.length) { + return false; + } + + for (let i = 0; i < segments_lexical.length; i++) { + if (segments_liveblocks[i][0] !== segments_lexical[i][0]) { + return false; + } + + const attrs_liveblocks = + segments_liveblocks[i].length > 1 + ? (segments_liveblocks[i][1] as TextAttributes) + : undefined; + const attrs_lexical = + segments_lexical[i].length > 1 + ? (segments_lexical[i][1] as TextAttributes) + : undefined; + + if (attrs_liveblocks === attrs_lexical) { + continue; + } + if (!isEqual(attrs_liveblocks, attrs_lexical)) { + return false; + } + } + + return true; +} + +function areElementNodesStructurallyEqual( + element_liveblocks: LiveElementNode, + element_lexical: ElementNode +): boolean { + element_lexical = element_lexical.getLatest(); + if (element_liveblocks.get("type") !== element_lexical.getType()) { + return false; + } + + const props_lexical = $getLexicalNodeProps(element_lexical); + const props_liveblocks = element_liveblocks.get("props"); + const props_liveblocks_json = + props_liveblocks !== undefined ? props_liveblocks.toJSON() : undefined; + if ( + !isEqual(props_lexical, props_liveblocks_json as JsonObject | undefined) + ) { + return false; + } + + const children_lexical = $normalizeLexicalChildren(element_lexical); + const children_liveblocks = element_liveblocks.get("children"); + + // Empty LiveText occupies no Lexical slot, so walk storage and only advance + // the Lexical cursor for children that have a Lexical span. + let lexicalIndex = 0; + for (let i = 0; i < children_liveblocks.length; i++) { + const child_liveblocks = children_liveblocks.get(i); + if (child_liveblocks === undefined) return false; + + const kind_liveblocks = child_liveblocks.get("kind"); + if (kind_liveblocks === "text") { + const span = (child_liveblocks as LiveTextNode) + .get("content") + .toJSON().length; + if (span === 0) { + continue; + } + const child_lexical = children_lexical[lexicalIndex]; + if (!$isTextNodeList(child_lexical)) return false; + if ( + !areTextNodesStructurallyEqual( + child_liveblocks as LiveTextNode, + child_lexical + ) + ) { + return false; + } + lexicalIndex++; + continue; + } + + const child_lexical = children_lexical[lexicalIndex]; + if (child_lexical === undefined || child_lexical instanceof Array) { + return false; + } + + if ($isElementNode(child_lexical)) { + if (kind_liveblocks !== "element") return false; + if ( + !areElementNodesStructurallyEqual( + child_liveblocks as LiveElementNode, + child_lexical + ) + ) { + return false; + } + lexicalIndex++; + continue; + } + + if ($isLineBreakNode(child_lexical)) { + if (kind_liveblocks !== "linebreak") return false; + lexicalIndex++; + continue; + } + + if ($isDecoratorNode(child_lexical)) { + if (kind_liveblocks !== "decorator") return false; + if ( + !areDecoratorNodesStructurallyEqual( + child_liveblocks as LiveDecoratorNode, + child_lexical + ) + ) { + return false; + } + lexicalIndex++; + continue; + } + + return false; + } + + return lexicalIndex === children_lexical.length; +} + +function areDecoratorNodesStructurallyEqual( + decorator_liveblocks: LiveDecoratorNode, + decorator_lexical: DecoratorNode +): boolean { + decorator_lexical = decorator_lexical.getLatest(); + if (decorator_liveblocks.get("type") !== decorator_lexical.getType()) { + return false; + } + + const props_lexical = $getLexicalNodeProps(decorator_lexical); + const props_liveblocks = decorator_liveblocks.get("props"); + const props_liveblocks_json = + props_liveblocks !== undefined ? props_liveblocks.toJSON() : undefined; + return isEqual( + props_lexical, + props_liveblocks_json as JsonObject | undefined + ); +} + +function getSegmentsInRange( + segments: Array<[text: string] | [text: string, attributes: TextAttributes]>, + options: { rangeStart: number; rangeEnd: number } +): Array<[text: string] | [text: string, attributes: TextAttributes]> { + const { rangeStart, rangeEnd } = options; + if (rangeEnd <= rangeStart) { + return []; + } + + const slice: Array< + [text: string] | [text: string, attributes: TextAttributes] + > = []; + let offset = 0; + + for (const segment of segments) { + const text = segment[0]; + const segmentStart = offset; + const segmentEnd = offset + text.length; + + if (segmentEnd > rangeStart && segmentStart < rangeEnd) { + const sliceStart = Math.max(rangeStart, segmentStart) - segmentStart; + const sliceEnd = Math.min(rangeEnd, segmentEnd) - segmentStart; + const sliceText = text.slice(sliceStart, sliceEnd); + + if (sliceText.length > 0) { + if (segment.length > 1) { + slice.push([sliceText, segment[1]!]); + } else { + slice.push([sliceText]); + } + } + } + + offset = segmentEnd; + } + + return slice; +} + +function getSegmentAttributesAtOffset( + segments: Array<[string] | [string, TextAttributes]>, + offset: number +): TextAttributes | undefined { + let position = 0; + for (const segment of segments) { + const length = segment[0].length; + if (offset >= position && offset <= position + length) { + if (segment.length > 1) { + return segment[1] as TextAttributes; + } + return undefined; + } + position += length; + } + return undefined; +} + +/** + * Defaults for TextNode exportJSON fields that we omit from LiveText when at + * their Lexical defaults (same shaping as `createSegmentsFromTextNodes`). + * Mark flags (`bold`, …) are absent-when-false, not listed here. + */ +const TEXT_ATTRIBUTE_DEFAULTS: Readonly> = { + type: "text", + mode: "normal", + detail: 0, + style: "", +}; + +/** + * Build a `updateFromJSON` payload for one LiveText segment. + */ +function createSerializedTextNodeFromLiveTextSegment( + text: string, + attributes: TextAttributes | undefined +): LexicalUpdateJSON { + const payload: Record = { + type: TEXT_ATTRIBUTE_DEFAULTS.type, + mode: TEXT_ATTRIBUTE_DEFAULTS.mode, + detail: TEXT_ATTRIBUTE_DEFAULTS.detail, + style: TEXT_ATTRIBUTE_DEFAULTS.style, + text, + format: 0, + }; + + let format = 0; + if (attributes !== undefined) { + for (const [key, value] of Object.entries(attributes)) { + if (key in TEXT_TYPE_TO_FORMAT) { + if (value) { + format |= TEXT_TYPE_TO_FORMAT[key]; + } + continue; + } + payload[key] = value; + } + } + payload.format = format; + + return payload as LexicalUpdateJSON; +} + +/** + * Build a LiveText `format()` patch that turns `current` segment attrs into + * `target` (from `createSegmentsFromTextNodes` / exportJSON shaping). + */ +function createLiveTextAttributesPatch( + target: TextAttributes, + slice: ReadonlyArray<[string] | [string, TextAttributes]> +): JsonObject { + const patch: JsonObject = {}; + + for (const key of Object.keys(TEXT_TYPE_TO_FORMAT)) { + const wanted = target[key] === true; + const uniform = + slice.length > 0 && + slice.every((part) => { + const attrs = part.length > 1 ? part[1]! : {}; + return attrs[key] === true; + }); + const present = slice.some((part) => { + const attrs = part.length > 1 ? part[1]! : {}; + return attrs[key] === true; + }); + + if (wanted ? !uniform : present) { + patch[key] = wanted ? true : null; + } + } + + const keys = new Set(Object.keys(target)); + for (const part of slice) { + if (part.length > 1) { + for (const key of Object.keys(part[1]!)) { + keys.add(key); + } + } + } + + for (const key of keys) { + if (key in TEXT_TYPE_TO_FORMAT) { + continue; + } + + const defaultValue = TEXT_ATTRIBUTE_DEFAULTS[key]; + const wantedRaw = target[key]; + + if (defaultValue !== undefined) { + const wanted = wantedRaw ?? defaultValue; + const values = slice.map((part) => { + const attrs = part.length > 1 ? part[1]! : {}; + return attrs[key] ?? defaultValue; + }); + const uniform = + slice.length > 0 && values.every((value) => value === values[0]); + + if (wanted === defaultValue) { + if (values.some((value) => value !== defaultValue)) { + patch[key] = null; + } + } else if (!uniform || values[0] !== wanted) { + patch[key] = wantedRaw as Json; + } + continue; + } + + const values = slice.map((part) => { + const attrs = part.length > 1 ? part[1]! : {}; + return attrs[key]; + }); + const present = values.some((value) => value !== undefined); + const uniform = + slice.length > 0 && values.every((value) => value === values[0]); + + if (wantedRaw === undefined) { + if (present) { + patch[key] = null; + } + } else if (!uniform || values[0] !== wantedRaw) { + patch[key] = wantedRaw; + } + } + + return patch; +} + +function createSegmentsFromTextNodes( + nodes: readonly TextNode[] +): Array<[string] | [string, TextAttributes]> { + let segments: Array<[string] | [string, TextAttributes]> = nodes.map( + (node) => { + // Single source of truth: each TextNode's public JSON contract. We only + // reshape for LiveText (readable marks, omit defaults) — no parallel + // reads from getFormat() / getMode() / etc. + const json = node.exportJSON() as Record; + const text = + typeof json.text === "string" ? json.text : node.getTextContent(); + const attributes: TextAttributes = {}; + + for (const [key, value] of Object.entries(json)) { + if (key === "text" || key === "version" || key === NODE_STATE_KEY) { + continue; + } + if (value === undefined || value === null) { + continue; + } + + if (key === "format") { + const format = typeof value === "number" ? value : 0; + if (format === 0) { + continue; + } + for (const [name, flag] of Object.entries(TEXT_TYPE_TO_FORMAT)) { + if (format & flag) { + attributes[name] = true; + } + } + continue; + } + + const defaultValue = TEXT_ATTRIBUTE_DEFAULTS[key]; + if (defaultValue !== undefined) { + if (value !== defaultValue) { + attributes[key] = value as Json; + } + continue; + } + + attributes[key] = value as Json; + } + + if (Object.keys(attributes).length === 0) { + return [text] as const; + } + return [text, attributes] as const; + } + ); + + if ( + segments.length === 1 && + segments[0][0] === "" && + segments[0].length === 1 + ) { + segments = []; + } + + return segments; +} + +const OMIT_FROM_LEXICAL_NODE_PROPS = new Set([ + "type", + "version", + "children", + "direction", + "format", + "indent", + "textFormat", + "textStyle", +]); + +/** + * Read Lexical element/decorator state that maps to storage `props`, using each + * node's `exportJSON()` contract rather than enumerating internal instance fields. + */ +export function $getLexicalNodeProps( + node: LexicalNode +): JsonObject | undefined { + const latest = node.getLatest(); + if (!$isElementNode(latest) && !$isDecoratorNode(latest)) { + return undefined; + } + + const json = latest.exportJSON() as Record; + const props: Record = {}; + + for (const [key, value] of Object.entries(json)) { + if (OMIT_FROM_LEXICAL_NODE_PROPS.has(key)) { + continue; + } + if (key === NODE_STATE_KEY) { + if (value !== undefined && value !== null && typeof value === "object") { + for (const [stateKey, stateValue] of Object.entries( + value as Record + )) { + props[stateKey] = stateValue; + } + } + continue; + } + props[key] = value; + } + + return Object.keys(props).length > 0 ? (props as JsonObject) : undefined; +} + +/** + * Apply storage `props` onto a Lexical element or decorator — inverse of + * `$getLexicalNodeProps`. + * + * Uses each node's `updateFromJSON()` so custom node fields are applied the + * same way Lexical does for copy/paste and persistence. When `props` is + * `undefined`, synced fields are reset from a fresh instance of the node type. + * + * Layout fields (`direction`, `format`, `indent`, …) are preserved from the + * current node; they are omitted from storage props by `$getLexicalNodeProps`. + */ +export function $setLexicalNodeProps( + node: LexicalNode, + props: JsonObject | undefined +): void { + const latest = node.getLatest(); + if (!$isElementNode(latest) && !$isDecoratorNode(latest)) { + return; + } + + const exported = latest.exportJSON() as Record; + let effectiveProps = props; + + const nodeFieldKeys = new Set(); + const typeInfo = $getEditor()._nodes.get(latest.getType()); + if (typeInfo !== undefined) { + const freshInstance = new typeInfo.klass(); + if ($isElementNode(freshInstance) || $isDecoratorNode(freshInstance)) { + const freshExported = freshInstance.exportJSON() as Record< + string, + unknown + >; + for (const key of Object.keys(freshExported)) { + if (!OMIT_FROM_LEXICAL_NODE_PROPS.has(key) && key !== NODE_STATE_KEY) { + nodeFieldKeys.add(key); + } + } + } + } + + if (effectiveProps === undefined) { + if (typeInfo !== undefined) { + const instance = new typeInfo.klass(); + if ($isElementNode(instance) || $isDecoratorNode(instance)) { + effectiveProps = $getLexicalNodeProps(instance); + } + } + } + + const payload: Record = {}; + + // Keep element layout fields out of storage props from being reset by a partial update. + for (const key of OMIT_FROM_LEXICAL_NODE_PROPS) { + if (key in exported) { + payload[key] = exported[key]; + } + } + + if (effectiveProps === undefined) { + if (Object.keys(payload).length > 0) { + latest + .getWritable() + .updateFromJSON(payload as LexicalUpdateJSON); + } + return; + } + + // Preserve declared node fields from the current node so a partial props update + // (including `{}`) does not reset unspecified fields — HeadingNode.updateFromJSON + // calls setTag(serializedNode.tag) and would clear the tag when it is omitted. + for (const key of nodeFieldKeys) { + if (key in exported) { + payload[key] = exported[key]; + } + } + + const statePayload: Record = {}; + const exportedState = exported[NODE_STATE_KEY]; + if (exportedState !== undefined && typeof exportedState === "object") { + Object.assign(statePayload, exportedState as Record); + } + + for (const [key, value] of Object.entries(effectiveProps)) { + if (nodeFieldKeys.has(key)) { + payload[key] = value; + } else { + statePayload[key] = value; + } + } + + if (Object.keys(statePayload).length > 0) { + payload[NODE_STATE_KEY] = statePayload; + } + + latest + .getWritable() + .updateFromJSON(payload as LexicalUpdateJSON); +} diff --git a/packages/liveblocks-lexical/src/react/liveblocks-collaboration-plugin.tsx b/packages/liveblocks-lexical/src/react/liveblocks-collaboration-plugin.tsx new file mode 100644 index 00000000000..a0dfb850cf7 --- /dev/null +++ b/packages/liveblocks-lexical/src/react/liveblocks-collaboration-plugin.tsx @@ -0,0 +1,41 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useRoom } from "@liveblocks/react"; +import { createContext, useEffect, useRef } from "react"; + +import { LiveblocksCollaboration } from "../collaboration"; +import type { LiveRootNode } from "../types"; + +export type LiveblocksCollaborationPluginProps = { + root: LiveRootNode; + children?: React.ReactNode; +}; + +export const LiveblocksCollaborationContext = + createContext(null); + +export function LiveblocksCollaborationPlugin({ + root, + children, +}: LiveblocksCollaborationPluginProps) { + const [editor] = useLexicalComposerContext(); + const room = useRoom(); + + const _collaboration = useRef(null); + if (_collaboration.current === null) { + _collaboration.current = new LiveblocksCollaboration(editor, room, root); + } + const collaboration = _collaboration.current; + + useEffect(() => { + collaboration.register(); + return () => { + collaboration.unregister(); + }; + }, [collaboration]); + + return ( + + {children} + + ); +} diff --git a/packages/liveblocks-lexical/src/react/remote-cursors.tsx b/packages/liveblocks-lexical/src/react/remote-cursors.tsx new file mode 100644 index 00000000000..4255ea470e5 --- /dev/null +++ b/packages/liveblocks-lexical/src/react/remote-cursors.tsx @@ -0,0 +1,263 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { createDOMRange, createRectsFromDOMRange } from "@lexical/selection"; +import { useOthers } from "@liveblocks/react"; +import { useLayoutEffect } from "@liveblocks/react/_private"; +import { + $getNodeByKey, + $isLineBreakNode, + type LexicalEditor, + type LexicalNode, +} from "lexical"; +import { useCallback, useContext, useEffect, useState } from "react"; + +import type { DecodedLexicalSelection } from "../manager"; +import type { LiveLexicalSelection } from "../types"; +import { LiveblocksCollaborationContext } from "./liveblocks-collaboration-plugin"; + +type OverlayRect = { + left: number; + top: number; + width: number; + height: number; + /** Collapsed caret vs non-collapsed selection highlight. */ + kind: "caret" | "selection"; +}; + +type RemoteOverlay = { + connectionId: number; + color: string; + selections: OverlayRect[]; +}; + +export function RemoteCursorsPlugin() { + const collaboration = useContext(LiveblocksCollaborationContext); + if (collaboration === null) { + throw new Error( + "'RemoteCursorsPlugin' must be used within a 'LiveblocksCollaborationPlugin'" + ); + } + const manager = collaboration.manager; + const root = collaboration.root; + const room = collaboration.room; + + const [editor] = useLexicalComposerContext(); + const others = useOthers(); + const [overlays, setOverlays] = useState([]); + + const updateOverlays = useCallback(() => { + if (manager.binding.reverse.size === 0) { + setOverlays([]); + return; + } + + const container = editor.getRootElement()?.parentElement; + if (container === null || container === undefined) { + setOverlays([]); + return; + } + + const containerRect = container.getBoundingClientRect(); + const nextOverlays: RemoteOverlay[] = []; + + // Do not use `editor.read()` — in Lexical 0.45 it always force-commits + // pending updates. This plugin also refreshes from a Storage deep- + // subscribe, and `room.history.undo()` notifies that subscriber + // synchronously inside the UNDO_COMMAND update. Force-committing there + // freezes the in-flight selection and crashes later transforms + // (`_cachedNodes` is read-only). Read the committed state instead. + editor.getEditorState().read( + () => { + for (const user of others) { + const selection = user.presence.selection as + | LiveLexicalSelection + | null + | undefined; + if (selection === null || selection === undefined) { + continue; + } + + const decoded = manager.$decodeSelection(selection); + if (decoded === null) { + continue; + } + + const rects = $getRemoteOverlayRects(editor, decoded, containerRect); + if (rects === null) { + continue; + } + + nextOverlays.push({ + connectionId: user.connectionId, + color: + typeof user.info?.color === "string" + ? user.info.color + : "#888888", + selections: rects, + }); + } + }, + { editor } + ); + + setOverlays(nextOverlays); + }, [editor, manager, others]); + + useLayoutEffect(() => { + updateOverlays(); + }, [updateOverlays]); + + useEffect(() => { + return editor.registerUpdateListener(() => { + updateOverlays(); + }); + }, [editor, updateOverlays]); + + useEffect(() => { + const container = editor.getRootElement()?.parentElement; + if (container === null || container === undefined) { + return; + } + + const handleLayoutChange = () => { + updateOverlays(); + }; + + container.addEventListener("scroll", handleLayoutChange, { passive: true }); + window.addEventListener("resize", handleLayoutChange, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleLayoutChange); + window.removeEventListener("resize", handleLayoutChange); + }; + }, [editor, updateOverlays]); + + useEffect(() => { + return room.subscribe( + root, + () => { + updateOverlays(); + }, + { isDeep: true } + ); + }, [room, root, updateOverlays]); + + return ( +
    + {overlays.flatMap((overlay) => { + return overlay.selections.map((rect, index) => ( +
    + )); + })} +
    + ); +} + +function $getRemoteOverlayRects( + editor: LexicalEditor, + decoded: DecodedLexicalSelection, + containerRect: DOMRect +): OverlayRect[] | null { + const anchorNode = $getNodeByKey(decoded.anchor.key); + const focusNode = $getNodeByKey(decoded.focus.key); + if (anchorNode === null || focusNode === null) { + return null; + } + + const range = createDOMRange( + editor, + anchorNode, + decoded.anchor.offset, + focusNode, + decoded.focus.offset + ); + if (range === null) { + return null; + } + + if (range.collapsed) { + return $getCollapsedCaretRect(editor, range, focusNode, containerRect); + } + + if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) { + const brElement = editor.getElementByKey(decoded.anchor.key); + if (brElement === null) { + return null; + } + + const brRect = brElement.getBoundingClientRect(); + + return [ + { + left: brRect.left - containerRect.left, + top: brRect.top - containerRect.top, + width: brRect.width, + height: brRect.height, + kind: "selection", + }, + ]; + } + + const selections = createRectsFromDOMRange(editor, range).map((rect) => { + return { + left: rect.left - containerRect.left, + top: rect.top - containerRect.top, + width: rect.width, + height: rect.height, + kind: "selection" as const, + }; + }); + + if (selections.length === 0) { + return null; + } + + return selections; +} + +function $getCollapsedCaretRect( + editor: LexicalEditor, + range: Range, + focusNode: LexicalNode, + containerRect: DOMRect +): OverlayRect[] | null { + let caretRect = range.getBoundingClientRect(); + + if ( + (caretRect.height === 0 || caretRect.width === 0) && + $isLineBreakNode(focusNode) + ) { + const brElement = editor.getElementByKey(focusNode.getKey()); + if (brElement === null) { + return null; + } + caretRect = brElement.getBoundingClientRect(); + } + + if (caretRect.height === 0) { + return null; + } + + return [ + { + left: caretRect.left - containerRect.left, + top: caretRect.top - containerRect.top, + width: 0, + height: caretRect.height, + kind: "caret", + }, + ]; +} diff --git a/packages/liveblocks-lexical/src/styles/index.css b/packages/liveblocks-lexical/src/styles/index.css new file mode 100644 index 00000000000..3619405db95 --- /dev/null +++ b/packages/liveblocks-lexical/src/styles/index.css @@ -0,0 +1,32 @@ +/************************************* + * Collaboration cursors * + *************************************/ + +.lb-lexical-cursors { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + isolation: isolate; + pointer-events: none; +} + +.lb-lexical-cursor-selection { + position: absolute; + background-color: color-mix( + in srgb, + var(--lb-lexical-cursor-color) 25%, + transparent + ); + border-radius: 1px; + pointer-events: none; + box-sizing: border-box; +} + +.lb-lexical-cursor-caret { + position: absolute; + width: 0; + border-left: 2px solid var(--lb-lexical-cursor-color); + pointer-events: none; + box-sizing: border-box; +} diff --git a/packages/liveblocks-lexical/src/types.ts b/packages/liveblocks-lexical/src/types.ts new file mode 100644 index 00000000000..1e9f16816ac --- /dev/null +++ b/packages/liveblocks-lexical/src/types.ts @@ -0,0 +1,81 @@ +import type { + Json, + LiveList, + LiveMap, + LiveObject, + LiveText, +} from "@liveblocks/client"; + +export type LiveTextShape = { + kind: "text"; + type: string; + version: number; + content: LiveText; + props?: LiveMap; +}; + +export type LiveLineBreakShape = { + kind: "linebreak"; + type: "linebreak"; + version: number; +}; + +export type LiveElementShape = { + kind: "element"; + type: string; + version: number; + children: LiveList; + props?: LiveMap; +}; + +export type LiveDecoratorShape = { + kind: "decorator"; + type: string; + version: number; + props?: LiveMap; +}; + +export type LiveChildShape = + | LiveTextShape + | LiveElementShape + | LiveLineBreakShape + | LiveDecoratorShape; + +/** Block-level root children: elements and non-inline decorators (e.g. HR). */ +export type LiveRootChildShape = LiveElementShape | LiveDecoratorShape; + +export type LiveRootShape = { + kind: "root"; + type: "root"; + version: number; + children: LiveList; +}; + +export type LiveStorageShape = LiveRootShape | LiveChildShape; + +export type LiveChildNode = LiveObject; +export type LiveRootChildNode = LiveObject; +export type LiveTextNode = LiveObject; +export type LiveElementNode = LiveObject; +export type LiveLineBreakNode = LiveObject; +export type LiveDecoratorNode = LiveObject; +export type LiveRootNode = LiveObject; +export type LiveStorageNode = LiveObject; + +/** Storage-relative selection endpoint (not Lexical node keys). */ +export type LiveLexicalPointType = "text" | "element"; + +export type LiveLexicalPoint = { + /** Stable LiveObject id for the bound storage node. */ + nodeId: string; + type: LiveLexicalPointType; + /** Character offset within LiveText (text) or child index (element). */ + offset: number; + /** LiveText.version at encode time; 0 for non-text points. */ + version: number; +}; + +export type LiveLexicalSelection = { + anchor: LiveLexicalPoint; + focus: LiveLexicalPoint; +}; diff --git a/packages/liveblocks-lexical/src/version.ts b/packages/liveblocks-lexical/src/version.ts new file mode 100644 index 00000000000..218f113e074 --- /dev/null +++ b/packages/liveblocks-lexical/src/version.ts @@ -0,0 +1,6 @@ +declare const __VERSION__: string; +declare const ROLLUP_FORMAT: string; + +export const PKG_NAME = "@liveblocks/lexical"; +export const PKG_VERSION = typeof __VERSION__ === "string" && __VERSION__; +export const PKG_FORMAT = typeof ROLLUP_FORMAT === "string" && ROLLUP_FORMAT; diff --git a/packages/liveblocks-lexical/styles.css.d.cts b/packages/liveblocks-lexical/styles.css.d.cts new file mode 100644 index 00000000000..9467aa3d30b --- /dev/null +++ b/packages/liveblocks-lexical/styles.css.d.cts @@ -0,0 +1 @@ +declare module "@liveblocks/lexical/styles.css"; diff --git a/packages/liveblocks-lexical/styles.css.d.ts b/packages/liveblocks-lexical/styles.css.d.ts new file mode 100644 index 00000000000..9467aa3d30b --- /dev/null +++ b/packages/liveblocks-lexical/styles.css.d.ts @@ -0,0 +1 @@ +declare module "@liveblocks/lexical/styles.css"; diff --git a/packages/liveblocks-lexical/tsconfig.json b/packages/liveblocks-lexical/tsconfig.json new file mode 100644 index 00000000000..0b34c89d543 --- /dev/null +++ b/packages/liveblocks-lexical/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../shared/tsconfig.common.json", + + "compilerOptions": { + "lib": ["dom", "es2022"], + "noUncheckedIndexedAccess": false + }, + "include": ["src", "rollup.config.js"] +} diff --git a/packages/liveblocks-lexical/vitest.config.ts b/packages/liveblocks-lexical/vitest.config.ts new file mode 100644 index 00000000000..25ed75d3a21 --- /dev/null +++ b/packages/liveblocks-lexical/vitest.config.ts @@ -0,0 +1,8 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "happy-dom", + include: ["src/**/*.test.[jt]s?(x)"], + }, +}); diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 41ccaf3e22c..60db275abb8 100644 --- a/packages/liveblocks-node-lexical/package.json +++ b/packages/liveblocks-node-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-lexical", - "version": "3.23.1", + "version": "3.24.0", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -30,6 +30,7 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:ci": "vitest run --coverage", "test:watch": "vitest" diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 7c340f03581..5731f947c3f 100644 --- a/packages/liveblocks-node-prosemirror/package.json +++ b/packages/liveblocks-node-prosemirror/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-prosemirror", - "version": "3.23.1", + "version": "3.24.0", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index cd82eac9fc9..43d1e2502cd 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.23.1", + "version": "3.24.0", "description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -30,6 +30,7 @@ "format": "(eslint --fix src/ || true) && prettier --write src/", "lint": "eslint src/", "lint:package": "publint --strict && attw --pack", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:ci": "vitest run --coverage", "test:types": "vitest run --config ./vitest.config.typecheck.ts", diff --git a/packages/liveblocks-node/src/Session.ts b/packages/liveblocks-node/src/Session.ts index 63abfdbf299..19e54ea1750 100644 --- a/packages/liveblocks-node/src/Session.ts +++ b/packages/liveblocks-node/src/Session.ts @@ -1,7 +1,7 @@ import type { IUserInfo, - Json, JsonObject, + ReadonlyJson, URLSafeString, } from "@liveblocks/core"; import { normalizeRoomPermissions, Permission, url } from "@liveblocks/core"; @@ -26,7 +26,7 @@ const FULL_ACCESS = Object.freeze([Permission.Write] as const); const roomPatternRegex = /^([*]|[^*]{1,128}[*]?)$/; -type PostFn = (path: URLSafeString, json: Json) => Promise; +type PostFn = (path: URLSafeString, json: ReadonlyJson) => Promise; /** * Class to help you construct the exact permission set to grant a user. diff --git a/packages/liveblocks-node/src/__tests__/client.test.ts b/packages/liveblocks-node/src/__tests__/client.test.ts index 4273b49ed0b..3d01891bccf 100644 --- a/packages/liveblocks-node/src/__tests__/client.test.ts +++ b/packages/liveblocks-node/src/__tests__/client.test.ts @@ -984,7 +984,7 @@ describe("client", () => { metadata: { color: "blue", }, - visibility: "private", + visibility: "private" as const, }; server.use( diff --git a/packages/liveblocks-node/src/client.ts b/packages/liveblocks-node/src/client.ts index 1c0e36ca3d6..44936504794 100644 --- a/packages/liveblocks-node/src/client.ts +++ b/packages/liveblocks-node/src/client.ts @@ -47,6 +47,7 @@ import type { PlainLsonObject, QueryMetadata, QueryParams, + ReadonlyJson, RoomAccesses, RoomPermissions, RoomSubscriptionSettings, @@ -1053,7 +1054,7 @@ export class Liveblocks { async #post( path: URLSafeString, - json: Json | undefined, + json: ReadonlyJson | undefined, options?: RequestOptions, params?: QueryParams ): Promise { @@ -1083,7 +1084,7 @@ export class Liveblocks { async #patch( path: URLSafeString, - json: Json, + json: ReadonlyJson, options?: RequestOptions ): Promise { const url = urljoin(this.#baseUrl, path); diff --git a/packages/liveblocks-node/src/index.ts b/packages/liveblocks-node/src/index.ts index c3166a3c8ab..c97931b5613 100644 --- a/packages/liveblocks-node/src/index.ts +++ b/packages/liveblocks-node/src/index.ts @@ -114,6 +114,7 @@ export { LiveList, LiveMap, LiveObject, + LiveText, stringifyCommentBody, } from "@liveblocks/core"; diff --git a/packages/liveblocks-prosemirror/.gitignore b/packages/liveblocks-prosemirror/.gitignore new file mode 100644 index 00000000000..5e4eff3eb02 --- /dev/null +++ b/packages/liveblocks-prosemirror/.gitignore @@ -0,0 +1,5 @@ +/scripts/*.js +**/*.css +**/*.css.map +!/src/**/*.css +!/src/**/*.css.map diff --git a/packages/liveblocks-prosemirror/.stylelintrc.cjs b/packages/liveblocks-prosemirror/.stylelintrc.cjs new file mode 100644 index 00000000000..3f9f5a2894f --- /dev/null +++ b/packages/liveblocks-prosemirror/.stylelintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + extends: ["stylelint-config-standard"], + rules: { + "selector-class-pattern": /^collaboration-carets__[a-z-]+$/, + }, +}; diff --git a/packages/liveblocks-prosemirror/README.md b/packages/liveblocks-prosemirror/README.md new file mode 100644 index 00000000000..7965858c554 --- /dev/null +++ b/packages/liveblocks-prosemirror/README.md @@ -0,0 +1,72 @@ +

    + Liveblocks + Liveblocks +

    + +# `@liveblocks/prosemirror` + +

    + NPM + Size + License +

    + +`@liveblocks/prosemirror` provides plugins that integrate +[ProseMirror](https://prosemirror.net/) editors with Liveblocks Storage. It +keeps editor documents in sync, stores text nodes as `LiveText`, and displays +remote carets and selections. + +If you are using Tiptap, use +[`@liveblocks/react-tiptap`](../liveblocks-react-tiptap) with +`collaborationMode: "liveblocks"` instead. It builds on this package and +provides a Tiptap extension and React components. + +This package is for client-side ProseMirror editors backed by Liveblocks +Storage. For server-side editing of existing Tiptap and BlockNote documents, use +[`@liveblocks/node-prosemirror`](../liveblocks-node-prosemirror). + +## Installation + +``` +npm install @liveblocks/client @liveblocks/prosemirror prosemirror-model prosemirror-state prosemirror-view +``` + +Import the package stylesheet to display remote carets and selections: + +```ts +import "@liveblocks/prosemirror/styles.css"; +``` + +## Documentation + +Read the +[documentation](https://liveblocks.io/docs/api-reference/liveblocks-prosemirror) +for setup instructions and API references. + +## Examples + +Explore our [collaborative examples](https://liveblocks.io/examples) to help you +get started. + +> All examples are open-source and live in this repository, within +> [`/examples`](../../examples). + +## Releases + +See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or +learn more about +[upcoming releases](https://github.com/liveblocks/liveblocks/milestones). + +## Community + +- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks + community, ask questions and share tips. +- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts, + and general Liveblocks tips. + +## License + +Licensed under the Apache License 2.0, Copyright © 2021-present +[Liveblocks](https://liveblocks.io). + +See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-prosemirror/eslint.config.mjs b/packages/liveblocks-prosemirror/eslint.config.mjs new file mode 100644 index 00000000000..d9c85c18afc --- /dev/null +++ b/packages/liveblocks-prosemirror/eslint.config.mjs @@ -0,0 +1,26 @@ +import { makeConfig } from "@liveblocks/eslint-config"; +import commonRestrictedSyntax from "@liveblocks/eslint-config/restricted-syntax"; + +export default [ + ...makeConfig(), + { + rules: { + "no-restricted-syntax": ["error", ...commonRestrictedSyntax], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off", + }, + }, + { + files: ["src/**/__tests__/**"], + rules: { + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-floating-promises": "off", + }, + }, +]; diff --git a/packages/liveblocks-prosemirror/package.json b/packages/liveblocks-prosemirror/package.json new file mode 100644 index 00000000000..bc33b4dbf60 --- /dev/null +++ b/packages/liveblocks-prosemirror/package.json @@ -0,0 +1,96 @@ +{ + "name": "@liveblocks/prosemirror", + "version": "3.24.0", + "description": "ProseMirror collaboration plugins backed by Liveblocks.", + "license": "Apache-2.0", + "author": "Liveblocks Inc.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "module": "./dist/index.js", + "default": "./dist/index.cjs" + } + }, + "./styles.css": { + "types": "./styles.css.d.cts", + "default": "./styles.css" + } + }, + "files": [ + "dist/**", + "**/*.css", + "**/*.css.d.cts", + "**/*.css.d.ts", + "**/*.css.map", + "README.md" + ], + "scripts": { + "dev": "rollup --config rollup.config.js --watch", + "build": "rollup --config rollup.config.js", + "format": "eslint --fix src/; stylelint --fix src/styles/; prettier --write src/", + "lint": "eslint src/; stylelint src/styles/", + "lint:package": "publint --strict && attw --pack", + "start": "pnpm run dev", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "test:watch": "vitest" + }, + "dependencies": { + "@liveblocks/client": "workspace:*", + "@liveblocks/core": "workspace:*" + }, + "peerDependencies": { + "prosemirror-model": "^1", + "prosemirror-state": "^1", + "prosemirror-view": "^1" + }, + "devDependencies": { + "@liveblocks/eslint-config": "workspace:*", + "@liveblocks/rollup-config": "workspace:*", + "@liveblocks/vitest-config": "workspace:*", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-document": "^3.22.3", + "@tiptap/extension-paragraph": "^3.22.3", + "@tiptap/extension-text": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "eslint": "^9.39.4", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.6", + "rollup": "3.28.1", + "stylelint": "^15.10.2", + "stylelint-config-standard": "^34.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + }, + "sideEffects": false, + "bugs": { + "url": "https://github.com/liveblocks/liveblocks/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/liveblocks/liveblocks.git", + "directory": "packages/liveblocks-prosemirror" + }, + "homepage": "https://liveblocks.io", + "keywords": [ + "prosemirror", + "liveblocks", + "real-time", + "collaboration", + "collaborative", + "presence", + "crdts", + "synchronize", + "rooms", + "documents" + ] +} diff --git a/packages/liveblocks-prosemirror/rollup.config.js b/packages/liveblocks-prosemirror/rollup.config.js new file mode 100644 index 00000000000..f10ab31ea41 --- /dev/null +++ b/packages/liveblocks-prosemirror/rollup.config.js @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { createConfig } from "@liveblocks/rollup-config"; + +import pkg from "./package.json" with { type: "json" }; + +export default createConfig({ + pkg, + entries: ["src/index.ts"], + styles: [ + { + entry: "src/styles/index.css", + destination: "styles.css", + }, + ], +}); diff --git a/packages/liveblocks-prosemirror/src/__tests__/collaboration-liveblocks.test.ts b/packages/liveblocks-prosemirror/src/__tests__/collaboration-liveblocks.test.ts new file mode 100644 index 00000000000..de974cee9ee --- /dev/null +++ b/packages/liveblocks-prosemirror/src/__tests__/collaboration-liveblocks.test.ts @@ -0,0 +1,1517 @@ +import type { LsonObject, StorageUpdate } from "@liveblocks/client"; +import { LiveList, LiveMap, LiveObject, LiveText } from "@liveblocks/client"; +import { kInternal, OpCode } from "@liveblocks/core"; +import { Editor, Extension, Mark, Node } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import { Slice } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { describe, expect, test, vi } from "vitest"; + +import { + createSerializedRoot, + prepareIsolatedStorageTest, +} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup"; +import { + createLiveblocksCollaborationCaretPlugin, + LIVEBLOCKS_CARET_PLUGIN_KEY, +} from "../cursors"; +import { + createLiveblocksCollaborationPlugin, + getLiveblocksProsemirrorDocument, + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, + LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY, +} from "../plugin"; +import { + applyRemoteLiveTextUpdates, + applyRemoteStorageUpdates, +} from "../remote"; +import { + createLiveblocksProsemirrorNode, + getLiveblocksNodeContent, + getLiveblocksNodeId, + getLiveblocksNodeText, + liveblocksProsemirrorNodeToJson, + type ProseMirrorJsonNode, + updateLiveblocksNodeAttrs, +} from "../schema"; +import { applyIncrementalOperations, classifyTransaction } from "../steps"; +import type { LiveblocksProsemirrorRoom } from "../types"; + +function createDefaultDocument(): ProseMirrorJsonNode { + return { type: "doc", content: [{ type: "paragraph" }] }; +} + +function liveblocksNodeToJson( + node: ReturnType +) { + return liveblocksProsemirrorNodeToJson(node, createDefaultDocument); +} + +const TestLiveblocksCollaborationCaret = Extension.create({ + name: "collaborationCaret", + addOptions() { + return { + room: undefined as LiveblocksProsemirrorRoom | undefined, + field: "default", + user: {}, + }; + }, + addStorage() { + return { + users: [], + }; + }, + addProseMirrorPlugins() { + return [ + createLiveblocksCollaborationCaretPlugin(this.options, this.storage), + ]; + }, +}); + +type TestLiveblocksCollaborationOptions = { + room?: LiveblocksProsemirrorRoom; + field: string; + initialContent?: ProseMirrorJsonNode; + fallbackDocument: () => ProseMirrorJsonNode; +}; + +const TestLiveblocksCollaboration = + Extension.create({ + name: "collaboration", + addOptions() { + return { + room: undefined as LiveblocksProsemirrorRoom | undefined, + field: "default", + initialContent: undefined as ProseMirrorJsonNode | undefined, + fallbackDocument: createDefaultDocument, + }; + }, + addProseMirrorPlugins() { + return [ + createLiveblocksCollaborationPlugin({ + room: this.options.room, + field: this.options.field, + initialContent: this.options.initialContent, + fallbackDocument: this.options.fallbackDocument, + }), + ]; + }, + }); + +const Bold = Mark.create({ + name: "bold", + parseHTML: () => [{ tag: "strong" }], + renderHTML: () => ["strong", 0], +}); + +const Panel = Node.create({ + name: "panel", + group: "block", + content: "inline*", + addAttributes: () => ({ + tone: { default: null }, + }), + parseHTML: () => [{ tag: "section" }], + renderHTML: () => ["section", 0], +}); + +function createEditor(content: string) { + return new Editor({ + extensions: [Document, Paragraph, Text, Bold], + content, + }); +} + +function createCollaborationTestRoom(root = new LiveObject({})) { + let onStorageUpdate: ((updates: StorageUpdate[]) => void) | undefined; + const room = { + batch(callback: () => void) { + callback(); + }, + getOthers() { + return []; + }, + getStorage: () => Promise.resolve({ root }), + history: { + canUndo: () => false, + canRedo: () => false, + disable: (callback: () => T) => callback(), + pause: vi.fn(), + resume: vi.fn(), + undo: () => {}, + redo: () => {}, + }, + subscribe( + _node: LiveObject, + callback: (updates: StorageUpdate[]) => void + ) { + onStorageUpdate = callback; + return () => { + onStorageUpdate = undefined; + }; + }, + updatePresence: () => {}, + events: { + others: { + subscribe: () => () => {}, + }, + }, + } satisfies LiveblocksProsemirrorRoom; + + return { + room, + root, + notifyStorageUpdate(updates: StorageUpdate[]) { + onStorageUpdate?.(updates); + }, + }; +} + +async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); +} + +async function createHistoryTestEditor(content: string) { + const { applyRemoteOperations, room, root } = + await prepareIsolatedStorageTest([createSerializedRoot()]); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content, + }); + await flushAsyncWork(); + + return { applyRemoteOperations, editor, room, root }; +} + +function createCaretTestRoom(initialPosition = 1) { + let onOthersUpdate: (() => void) | undefined; + let presence = { + liveblocksTiptap: { + field: "default", + anchor: initialPosition, + head: initialPosition, + user: { name: "Ada", color: "#f00" }, + }, + }; + + const room = { + batch(callback: () => void) { + callback(); + }, + getOthers() { + return [ + { + connectionId: 1, + presence, + }, + ]; + }, + getStorage: () => + Promise.reject(new Error("Unexpected storage access in caret test")), + history: { + canUndo: () => false, + canRedo: () => false, + disable: (callback: () => T) => callback(), + pause: () => {}, + resume: () => {}, + undo: () => {}, + redo: () => {}, + }, + subscribe: () => () => {}, + updatePresence: () => {}, + events: { + others: { + subscribe(callback: () => void) { + onOthersUpdate = callback; + return () => { + onOthersUpdate = undefined; + }; + }, + }, + }, + } satisfies LiveblocksProsemirrorRoom; + + return { + room, + setRemoteCursor(position: number) { + presence = { + liveblocksTiptap: { + ...presence.liveblocksTiptap, + anchor: position, + head: position, + }, + }; + onOthersUpdate?.(); + }, + }; +} + +function getRemoteCaretWidgetPosition(editor: Editor): number | undefined { + return LIVEBLOCKS_CARET_PLUGIN_KEY.getState( + editor.state + )?.decorations.find()[0]?.from; +} + +function isProseMirrorJsonNode(value: unknown): value is ProseMirrorJsonNode { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function getDocumentJson(doc: ProseMirrorNode): ProseMirrorJsonNode { + const json: unknown = doc.toJSON(); + if (!isProseMirrorJsonNode(json)) { + throw new Error("Expected ProseMirror document JSON"); + } + + return json; +} + +function getFirstTextNode( + root: ReturnType +) { + const docContent = getLiveblocksNodeContent(root); + const paragraph = docContent?.get(0); + const paragraphContent = + paragraph !== undefined ? getLiveblocksNodeContent(paragraph) : undefined; + return paragraphContent?.get(0); +} + +describe("collaboration-liveblocks schema", () => { + test("gets a ProseMirror document by field from the Storage root", () => { + const document = createLiveblocksProsemirrorNode({ + type: "doc", + content: [{ type: "paragraph" }], + }); + const root = new LiveObject({ + [LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY]: new LiveMap([["custom", document]]), + }); + + expect(getLiveblocksProsemirrorDocument(root, "custom")).toBe(document); + expect(getLiveblocksProsemirrorDocument(root, "other")).toBeUndefined(); + }); + + test("round-trips a ProseMirror document through Liveblocks storage nodes", () => { + const document = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { textAlign: "left" }, + content: [ + { + type: "text", + text: "Hello", + marks: [{ type: "bold" }], + }, + { + type: "text", + text: " world", + }, + ], + }, + ], + }; + + const storageNode = createLiveblocksProsemirrorNode(document); + const paragraph = getLiveblocksNodeContent(storageNode)?.get(0); + const attrs = paragraph?.get("attrs"); + + expect(attrs).toBeInstanceOf(LiveMap); + expect(attrs?.toJSON()).toEqual({ textAlign: "left" }); + expect(liveblocksNodeToJson(storageNode)).toEqual(document); + if (paragraph === undefined) { + return; + } + + updateLiveblocksNodeAttrs(paragraph, { + textAlign: "center", + metadata: { color: "red" }, + }); + + const updatedAttrs = paragraph.get("attrs"); + expect(updatedAttrs).toBeInstanceOf(LiveMap); + expect(updatedAttrs).not.toBe(attrs); + expect(liveblocksNodeToJson(storageNode).content?.[0]?.attrs).toEqual({ + textAlign: "center", + metadata: { color: "red" }, + }); + }); + + test("applies plain text insertion to an existing LiveText node", () => { + const editor = createEditor("

    Hello

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const textNodeId = getLiveblocksNodeId(textNode!); + + const tr = oldState.tr.insertText("!", 6); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } + + const nextTextNode = getFirstTextNode(storageNode); + expect(nextTextNode).toBeDefined(); + expect(getLiveblocksNodeId(nextTextNode!)).toBe(textNodeId); + expect(getLiveblocksNodeText(nextTextNode!)?.toString()).toBe("Hello!"); + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies text deletion to an existing LiveText node", () => { + const editor = createEditor("

    Hello!

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const textNodeId = getLiveblocksNodeId(textNode!); + + const tr = oldState.tr.delete(6, 7); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } + + const nextTextNode = getFirstTextNode(storageNode); + expect(nextTextNode).toBeDefined(); + expect(getLiveblocksNodeId(nextTextNode!)).toBe(textNodeId); + expect(getLiveblocksNodeText(nextTextNode!)?.toString()).toBe("Hello"); + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies mark changes to LiveText formatting", () => { + const editor = createEditor("

    Hello

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const textNodeId = getLiveblocksNodeId(textNode!); + const bold = oldState.schema.marks.bold; + + const tr = oldState.tr.addMark(1, 6, bold.create()); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } + + const nextTextNode = getFirstTextNode(storageNode); + expect(nextTextNode).toBeDefined(); + expect(getLiveblocksNodeId(nextTextNode!)).toBe(textNodeId); + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies local paragraph insertion to the existing LiveList", () => { + const editor = createEditor("

    Hello

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + const firstParagraph = content!.get(0); + expect(firstParagraph).toBeDefined(); + const firstParagraphId = getLiveblocksNodeId(firstParagraph!); + const paragraphType = oldState.schema.nodes.paragraph; + expect(paragraphType).toBeDefined(); + const tr = oldState.tr.insert( + oldState.doc.content.size, + paragraphType.create(undefined, oldState.schema.text("World")) + ); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } + + const nextFirstParagraph = content!.get(0); + expect(nextFirstParagraph).toBeDefined(); + expect(getLiveblocksNodeId(nextFirstParagraph!)).toBe(firstParagraphId); + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies local paragraph deletion to the existing LiveList", () => { + const editor = createEditor("

    Hello

    World

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const from = oldState.doc.child(0).nodeSize; + const to = from + oldState.doc.child(1).nodeSize; + const tr = oldState.tr.delete(from, to); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } + + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies local paragraph split without replacing the document root", () => { + const editor = createEditor("

    Hello

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const tr = oldState.tr.split(3); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + expect(classified.operations).toEqual([ + expect.objectContaining({ type: "setNode", index: 0 }), + expect.objectContaining({ type: "insertNode", index: 1 }), + ]); + applyIncrementalOperations(classified.operations); + } + + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies local paragraph merge without replacing the document root", () => { + const editor = createEditor("

    Hello

    World

    "); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const tr = oldState.tr.join(oldState.doc.child(0).nodeSize); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + expect(classified.operations).toEqual([ + expect.objectContaining({ type: "setNode", index: 0 }), + expect.objectContaining({ type: "deleteNode", index: 1 }), + ]); + applyIncrementalOperations(classified.operations); + } + + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("applies local whole-node replacement to the existing LiveList", () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Panel], + content: "

    Hello

    ", + }); + const oldState = editor.state; + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(oldState.doc) + ); + const panelType = oldState.schema.nodes.panel; + expect(panelType).toBeDefined(); + const tr = oldState.tr.replaceWith( + 0, + oldState.doc.child(0).nodeSize, + panelType.create(undefined, oldState.schema.text("World")) + ); + const newState = oldState.apply(tr); + const classified = classifyTransaction( + [tr], + oldState.doc, + newState.doc, + storageNode + ); + + expect(classified.type).toBe("incremental"); + if (classified.type === "incremental") { + expect(classified.operations).toEqual([ + expect.objectContaining({ type: "setNode", index: 0 }), + ]); + applyIncrementalOperations(classified.operations); + } + + expect(liveblocksNodeToJson(storageNode)).toEqual(newState.doc.toJSON()); + + editor.destroy(); + }); + + test("stores collaboration documents inside the reserved documents map", async () => { + const { room, root } = createCollaborationTestRoom(); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content: "

    Hello

    ", + }); + + await flushAsyncWork(); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + expect(documents).toBeInstanceOf(LiveMap); + + const storedDocument = + documents instanceof LiveMap ? documents.get("default") : undefined; + expect(storedDocument).toBeInstanceOf(LiveObject); + expect(liveblocksNodeToJson(storedDocument!)).toEqual(editor.getJSON()); + + editor.destroy(); + }); + + test("stores different collaboration fields as separate documents", async () => { + const root = new LiveObject({}); + const first = createCollaborationTestRoom(root); + const second = createCollaborationTestRoom(root); + const firstEditor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ + room: first.room, + field: "one", + }), + ], + content: "

    Hello

    ", + }); + const secondEditor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ + room: second.room, + field: "two", + }), + ], + content: "

    World

    ", + }); + + await flushAsyncWork(); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + expect(documents).toBeInstanceOf(LiveMap); + + const firstDocument = + documents instanceof LiveMap ? documents.get("one") : undefined; + const secondDocument = + documents instanceof LiveMap ? documents.get("two") : undefined; + expect(firstDocument).toBeInstanceOf(LiveObject); + expect(secondDocument).toBeInstanceOf(LiveObject); + expect(liveblocksNodeToJson(firstDocument!)).toEqual(firstEditor.getJSON()); + expect(liveblocksNodeToJson(secondDocument!)).toEqual( + secondEditor.getJSON() + ); + + firstEditor.destroy(); + secondEditor.destroy(); + }); + + test("writes local collaboration updates back into the matching documents map entry", async () => { + const { room, root } = createCollaborationTestRoom(); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room, field: "body" }), + ], + content: "

    Hello

    ", + }); + + await flushAsyncWork(); + + editor.commands.setContent("

    Hello world

    "); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + const storedDocument = + documents instanceof LiveMap ? documents.get("body") : undefined; + expect(storedDocument).toBeInstanceOf(LiveObject); + expect(liveblocksNodeToJson(storedDocument!)).toEqual(editor.getJSON()); + + editor.destroy(); + }); + + test("rebinds storage after the editor plugins are reconfigured", async () => { + const storedDocument = createLiveblocksProsemirrorNode({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Stored" }], + }, + ], + }); + const root = new LiveObject({ + [LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY]: new LiveMap([ + ["default", storedDocument], + ]), + }); + const { room } = createCollaborationTestRoom(root); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content: "

    ", + }); + + editor.registerPlugin( + new Plugin({ key: new PluginKey("reconfigure-collaboration-test") }) + ); + await flushAsyncWork(); + + expect(editor.getText()).toBe("Stored"); + + editor.commands.setContent("

    Updated

    "); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + const updatedDocument = + documents instanceof LiveMap ? documents.get("default") : undefined; + expect(updatedDocument).toBeInstanceOf(LiveObject); + expect(liveblocksNodeToJson(updatedDocument!)).toEqual(editor.getJSON()); + + editor.destroy(); + }); + + test("groups typing into one Liveblocks history item until typing is idle", async () => { + vi.useFakeTimers(); + const { room } = createCollaborationTestRoom(); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content: "

    ", + }); + + await flushAsyncWork(); + + editor.commands.insertContentAt(1, "H"); + vi.advanceTimersByTime(250); + editor.commands.insertContentAt(2, "i"); + + expect(room.history.pause).toHaveBeenCalledTimes(2); + expect(room.history.resume).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(499); + expect(room.history.resume).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(room.history.resume).toHaveBeenCalledTimes(1); + + editor.destroy(); + vi.useRealTimers(); + }); + + test("commits an open history group when the editor is destroyed", async () => { + vi.useFakeTimers(); + const { room } = createCollaborationTestRoom(); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content: "

    ", + }); + + await flushAsyncWork(); + editor.commands.insertContentAt(1, "H"); + editor.destroy(); + + expect(room.history.resume).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + test("restores a collapsed cursor when undoing and redoing text", async () => { + const { editor, room } = await createHistoryTestEditor("

    Hello

    "); + + editor.commands.setTextSelection(6); + editor.commands.insertContent("!"); + expect(editor.getText()).toBe("Hello!"); + expect(editor.state.selection.anchor).toBe(7); + + room.history.resume(); + room.history.undo(); + expect(editor.getText()).toBe("Hello"); + expect(editor.state.selection.anchor).toBe(6); + expect(editor.state.selection.head).toBe(6); + + room.history.redo(); + expect(editor.getText()).toBe("Hello!"); + expect(editor.state.selection.anchor).toBe(7); + expect(editor.state.selection.head).toBe(7); + + editor.destroy(); + }); + + test("restores a deleted range instead of mapping it to the range end", async () => { + const { editor, room } = await createHistoryTestEditor( + "

    Hello, world

    " + ); + + editor.commands.setTextSelection({ from: 1, to: 6 }); + editor.commands.deleteRange({ from: 1, to: 6 }); + expect(editor.getText()).toBe(", world"); + expect(editor.state.selection.anchor).toBe(1); + + room.history.resume(); + room.history.undo(); + expect(editor.getText()).toBe("Hello, world"); + expect(editor.state.selection.anchor).toBe(1); + expect(editor.state.selection.head).toBe(6); + + room.history.redo(); + expect(editor.getText()).toBe(", world"); + expect(editor.state.selection.anchor).toBe(1); + expect(editor.state.selection.head).toBe(1); + + editor.destroy(); + }); + + test("restores selection when undo rebuilds an emptied text block", async () => { + const { editor, room } = await createHistoryTestEditor("

    Hello

    "); + + editor.commands.setTextSelection({ from: 1, to: 6 }); + editor.commands.deleteRange({ from: 1, to: 6 }); + expect(editor.getText()).toBe(""); + + room.history.resume(); + room.history.undo(); + expect(editor.getText()).toBe("Hello"); + expect(editor.state.selection.anchor).toBe(1); + expect(editor.state.selection.head).toBe(6); + + room.history.redo(); + expect(editor.getText()).toBe(""); + expect(editor.state.selection.anchor).toBe(1); + expect(editor.state.selection.head).toBe(1); + + editor.destroy(); + }); + + test("rebases a stored cursor over a peer edit before undo", async () => { + const { applyRemoteOperations, editor, room, root } = + await createHistoryTestEditor("

    Hello

    "); + + editor.commands.setTextSelection(6); + editor.commands.insertContent("!"); + expect(editor.getText()).toBe("Hello!"); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + expect(documents).toBeInstanceOf(LiveMap); + const documentRoot = + documents instanceof LiveMap ? documents.get("default") : undefined; + expect(documentRoot).toBeInstanceOf(LiveObject); + const textNode = documentRoot ? getFirstTextNode(documentRoot) : undefined; + const text = textNode ? getLiveblocksNodeText(textNode) : undefined; + const textId = text?.[kInternal].getId(); + expect(text).toBeDefined(); + expect(textId).toBeDefined(); + if (text === undefined || textId === undefined) { + throw new Error("Expected an attached LiveText node"); + } + + applyRemoteOperations([ + { + type: OpCode.UPDATE_TEXT, + id: textId, + baseVersion: text.version, + version: text.version + 1, + ops: [{ type: "insert", index: 0, text: "X" }], + }, + ]); + expect(editor.getText()).toBe("XHello!"); + + room.history.resume(); + room.history.undo(); + expect(editor.getText()).toBe("XHello"); + expect(editor.state.selection.anchor).toBe(7); + expect(editor.state.selection.head).toBe(7); + + editor.destroy(); + }); + + test("ignores storage echoes for local LiveText updates", async () => { + const { room, root, notifyStorageUpdate } = createCollaborationTestRoom(); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaboration.configure({ room }), + ], + content: "

    Hello

    ", + }); + + await flushAsyncWork(); + + editor.commands.insertContentAt(6, "!"); + + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + const storedDocument = + documents instanceof LiveMap ? documents.get("default") : undefined; + expect(storedDocument).toBeInstanceOf(LiveObject); + const textNode = getFirstTextNode(storedDocument!); + expect(textNode).toBeDefined(); + const text = getLiveblocksNodeText(textNode!); + expect(text).toBeInstanceOf(LiveText); + + const localUpdate: StorageUpdate = { + type: "LiveText", + node: text!, + updates: [ + { + type: "insert", + index: 5, + text: "!", + }, + ], + source: { origin: "local", via: "edit" }, + }; + + notifyStorageUpdate([localUpdate]); + + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello!" }], + }, + ], + }); + + editor.destroy(); + }); + + test("applies remote LiveList insert updates to the editor document", () => { + const editor = createEditor("

    Hello

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + const inserted = createLiveblocksProsemirrorNode({ + type: "paragraph", + content: [{ type: "text", text: "World" }], + }); + + editor.commands.setTextSelection(3); + content!.insert(inserted, 1); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: content!, + updates: [{ type: "insert", index: 1, item: inserted }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + expect(editor.state.selection.anchor).toBe(3); + + editor.destroy(); + }); + + test("applies remote attribute updates to a node", () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Panel], + content: { + type: "doc", + content: [ + { + type: "panel", + attrs: { tone: "info" }, + content: [{ type: "text", text: "Notice" }], + }, + ], + }, + }); + const storageNode = createLiveblocksProsemirrorNode(editor.getJSON()); + const panelNode = getLiveblocksNodeContent(storageNode)?.get(0); + const attrs = panelNode?.get("attrs"); + expect(attrs).toBeInstanceOf(LiveMap); + if (panelNode === undefined) { + return; + } + + updateLiveblocksNodeAttrs(panelNode, { tone: "warning" }); + expect(panelNode.get("attrs")).not.toBe(attrs); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveObject", + node: panelNode, + updates: { attrs: { type: "update" } }, + source: { origin: "remote" }, + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("does not double-apply nested LiveText updates covered by a remote node insert", () => { + const editor = createEditor("

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const documentContent = getLiveblocksNodeContent(storageNode); + expect(documentContent).toBeDefined(); + const paragraph = documentContent!.get(0); + expect(paragraph).toBeDefined(); + const paragraphContent = getLiveblocksNodeContent(paragraph!); + expect(paragraphContent).toBeDefined(); + const inserted = createLiveblocksProsemirrorNode({ + type: "text", + text: "f", + }); + const text = getLiveblocksNodeText(inserted); + expect(text).toBeDefined(); + + paragraphContent!.insert(inserted, 0); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: paragraphContent!, + updates: [{ type: "insert", index: 0, item: inserted }], + }, + { + type: "LiveText", + node: text!, + version: text!.version, + updates: [{ type: "insert", index: 0, text: "f" }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("does not double-apply nested LiveText updates after a remote node insert", () => { + const editor = createEditor("

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const documentContent = getLiveblocksNodeContent(storageNode); + expect(documentContent).toBeDefined(); + const paragraph = documentContent!.get(0); + expect(paragraph).toBeDefined(); + const paragraphContent = getLiveblocksNodeContent(paragraph!); + expect(paragraphContent).toBeDefined(); + const inserted = createLiveblocksProsemirrorNode({ + type: "text", + text: "f", + }); + const text = getLiveblocksNodeText(inserted); + expect(text).toBeDefined(); + + paragraphContent!.insert(inserted, 0); + const insertResult = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: paragraphContent!, + updates: [{ type: "insert", index: 0, item: inserted }], + }, + ]); + + expect(insertResult.type).toBe("applied"); + if (insertResult.type === "applied") { + editor.view.dispatch(insertResult.tr); + } + + const textResult = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveText", + node: text!, + version: text!.version, + updates: [{ type: "insert", index: 0, text: "f" }], + }, + ]); + + expect(textResult.type).toBe("applied"); + if (textResult.type === "applied") { + editor.view.dispatch(textResult.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("does not replay a LiveList insert already present from the storage snapshot", () => { + const editor = createEditor("

    f

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const documentContent = getLiveblocksNodeContent(storageNode); + expect(documentContent).toBeDefined(); + const paragraph = documentContent!.get(0); + expect(paragraph).toBeDefined(); + const paragraphContent = getLiveblocksNodeContent(paragraph!); + expect(paragraphContent).toBeDefined(); + + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: paragraphContent!, + updates: [{ type: "insert", index: 0, item: textNode! }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote LiveList delete updates to the editor document", () => { + const editor = createEditor("

    Hello

    World

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + const deletedItem = content!.get(1); + expect(deletedItem).toBeDefined(); + + content!.delete(1); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: content!, + updates: [{ type: "delete", index: 1, deletedItem: deletedItem! }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote LiveList set updates to the editor document", () => { + const editor = createEditor("

    Hello

    World

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + const replacement = createLiveblocksProsemirrorNode({ + type: "paragraph", + content: [{ type: "text", text: "Everyone" }], + }); + + content!.set(1, replacement); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: content!, + updates: [{ type: "set", index: 1, item: replacement }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote paragraph split updates using updated positions", () => { + const editor = createEditor("

    Hello

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + const firstHalf = createLiveblocksProsemirrorNode({ + type: "paragraph", + content: [{ type: "text", text: "He" }], + }); + const secondHalf = createLiveblocksProsemirrorNode({ + type: "paragraph", + content: [{ type: "text", text: "llo" }], + }); + + content!.set(0, firstHalf); + content!.insert(secondHalf, 1); + const result = applyRemoteStorageUpdates(editor.view, storageNode, [ + { + type: "LiveList", + node: content!, + updates: [ + { type: "set", index: 0, item: firstHalf }, + { type: "insert", index: 1, item: secondHalf }, + ], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote LiveText insert updates to the editor document", () => { + const editor = createEditor("

    Hello

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const text = getLiveblocksNodeText(textNode!); + expect(text).toBeDefined(); + + text!.insert(5, "!"); + const result = applyRemoteLiveTextUpdates(editor.view, storageNode, [ + { + type: "LiveText", + node: text!, + version: text!.version, + updates: [{ type: "insert", index: 5, text: "!" }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote LiveText delete updates to the editor document", () => { + const editor = createEditor("

    Hello!

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const text = getLiveblocksNodeText(textNode!); + expect(text).toBeDefined(); + + text!.delete(5, 1); + const result = applyRemoteLiveTextUpdates(editor.view, storageNode, [ + { + type: "LiveText", + node: text!, + version: text!.version, + updates: [{ type: "delete", index: 5, length: 1, deletedText: "!" }], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("applies remote LiveText delete when clearing the entire text node", () => { + const editor = createEditor("

    Hello from LiveText-backed Tiptap.

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const textNode = getFirstTextNode(storageNode); + expect(textNode).toBeDefined(); + const text = getLiveblocksNodeText(textNode!); + expect(text).toBeDefined(); + + const deletedLength = text!.length; + text!.delete(0, deletedLength); + + const result = applyRemoteLiveTextUpdates(editor.view, storageNode, [ + { + type: "LiveText", + node: text!, + version: text!.version, + updates: [ + { + type: "delete", + index: 0, + length: deletedLength, + deletedText: "Hello from LiveText-backed Tiptap.", + }, + ], + }, + ]); + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("falls back safely when remote updates delete all formatted text", () => { + const editor = createEditor("

    Hello world

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const paragraph = getLiveblocksNodeContent(storageNode)?.get(0); + const paragraphContent = paragraph + ? getLiveblocksNodeContent(paragraph) + : undefined; + + for (let index = 0; index < (paragraphContent?.length ?? 0); index++) { + const textNode = paragraphContent?.get(index); + const text = textNode ? getLiveblocksNodeText(textNode) : undefined; + text?.delete(0, text.length); + } + + const document = liveblocksNodeToJson(storageNode); + expect(document).toEqual({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + const view = editor.view; + const nextDocument = view.state.schema.nodeFromJSON(document); + const diffStart = view.state.doc.content.findDiffStart( + nextDocument.content + ); + expect(diffStart).not.toBeNull(); + + const diffEnd = view.state.doc.content.findDiffEnd(nextDocument.content); + expect(() => { + const tr = + diffEnd === null + ? view.state.tr.replace( + 0, + view.state.doc.content.size, + new Slice(nextDocument.content, 0, 0) + ) + : view.state.tr.replace( + diffStart!, + diffEnd.a, + nextDocument.slice(diffStart!, diffEnd.b) + ); + view.dispatch(tr); + }).not.toThrow(); + expect(editor.getJSON()).toEqual(document); + + editor.destroy(); + }); + + test("applies remote LiveText delete when clearing multi-segment formatted text", () => { + const editor = createEditor("

    Hello world

    "); + const text = new LiveText([ + ["Hello", { __liveblocks_tiptap_marks: [{ type: "bold" }] }], + [" world"], + ]); + const storageNode = new LiveObject({ + id: "doc", + type: "doc", + content: new LiveList([ + new LiveObject({ + id: "paragraph", + type: "paragraph", + content: new LiveList([ + new LiveObject({ + id: "text", + type: "text", + text, + }), + ]), + }), + ]), + }) as unknown as ReturnType; + + const deletedLength = text.length; + text.delete(0, deletedLength); + + const result = applyRemoteLiveTextUpdates(editor.view, storageNode, [ + { + type: "LiveText", + node: text, + version: text.version, + updates: [ + { + type: "delete", + index: 0, + length: deletedLength, + deletedText: "Hello world", + }, + ], + }, + ]); + + expect(result.type).toBe("applied"); + if (result.type === "applied") { + editor.view.dispatch(result.tr); + } + expect(editor.getJSON()).toEqual(liveblocksNodeToJson(storageNode)); + + editor.destroy(); + }); + + test("falls back safely when remote updates delete the last paragraph", () => { + const editor = createEditor("

    Hello from LiveText-backed Tiptap.

    "); + const storageNode = createLiveblocksProsemirrorNode( + getDocumentJson(editor.state.doc) + ); + const content = getLiveblocksNodeContent(storageNode); + expect(content).toBeDefined(); + + content!.delete(0); + const document = liveblocksNodeToJson(storageNode); + expect(document).toEqual({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + const view = editor.view; + const nextDocument = view.state.schema.nodeFromJSON(document); + + expect(() => { + view.dispatch( + view.state.tr.replace( + 0, + view.state.doc.content.size, + new Slice(nextDocument.content, 0, 0) + ) + ); + }).not.toThrow(); + + editor.destroy(); + }); + + test("renders stale end-of-paragraph carets inside the previous text block", () => { + const { room, setRemoteCursor } = createCaretTestRoom(6); + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + TestLiveblocksCollaborationCaret.configure({ room }), + ], + content: "

    Hello!

    World

    ", + }); + + setRemoteCursor(7); + editor.view.dispatch( + editor.state.tr + .delete(6, 7) + .setMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, { isRemote: true }) + ); + + expect( + LIVEBLOCKS_CARET_PLUGIN_KEY.getState(editor.state)?.cursors[0]?.head + ).toBe(7); + expect(getRemoteCaretWidgetPosition(editor)).toBe(6); + + editor.destroy(); + }); +}); diff --git a/packages/liveblocks-prosemirror/src/cursors.ts b/packages/liveblocks-prosemirror/src/cursors.ts new file mode 100644 index 00000000000..067ee61d235 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/cursors.ts @@ -0,0 +1,334 @@ +import type { JsonObject } from "@liveblocks/client"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import { Plugin, PluginKey, Selection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +import { LIVEBLOCKS_COLLABORATION_PLUGIN_KEY } from "./plugin"; +import type { LiveblocksProsemirrorRoom } from "./types"; + +export const LIVEBLOCKS_CARET_PRESENCE_KEY = "liveblocksTiptap"; + +export type CursorUser = { + name?: string; + color?: string; +}; + +type CursorPresence = { + field: string; + anchor: number; + head: number; + user?: CursorUser; +}; + +export type CollaborationCaretStorage = { + users: { clientId: number; [key: string]: unknown }[]; +}; + +export type RemoteCursor = { + anchor: number; + connectionId: number; + head: number; + rawAnchor: number; + rawHead: number; + user?: CursorUser; +}; + +export type CollaborationCaretPluginState = { + cursors: RemoteCursor[]; + decorations: DecorationSet; +}; + +export type CollaborationCaretOptions = { + room?: LiveblocksProsemirrorRoom; + field: string; + user: CursorUser; +}; + +export const LIVEBLOCKS_CARET_PLUGIN_KEY = + new PluginKey( + "liveblocks-collaboration-caret" + ); + +function isCursorPresence(value: unknown): value is CursorPresence { + return ( + typeof value === "object" && + value !== null && + typeof (value as { field?: unknown }).field === "string" && + typeof (value as { anchor?: unknown }).anchor === "number" && + typeof (value as { head?: unknown }).head === "number" + ); +} + +export function getCursorUser(value: unknown): CursorUser | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const user = value as { name?: unknown; color?: unknown }; + const name = typeof user.name === "string" ? user.name : undefined; + const color = typeof user.color === "string" ? user.color : undefined; + + return name !== undefined || color !== undefined + ? { name, color } + : undefined; +} + +export function presencePatch(presence: CursorPresence): JsonObject { + const user = getCursorUser(presence.user); + + return { + [LIVEBLOCKS_CARET_PRESENCE_KEY]: { + field: presence.field, + anchor: presence.anchor, + head: presence.head, + ...(user !== undefined ? { user } : {}), + }, + }; +} + +function createCursorElement(user: CursorUser | undefined): HTMLElement { + const color = user?.color ?? "#0f83ff"; + const name = user?.name ?? "Anonymous"; + const cursor = document.createElement("span"); + + cursor.classList.add("collaboration-carets__caret"); + cursor.setAttribute("style", `border-color: ${color}`); + + const label = document.createElement("div"); + label.classList.add("collaboration-carets__label"); + label.setAttribute("style", `background-color: ${color}`); + label.insertBefore(document.createTextNode(name), null); + cursor.insertBefore(label, null); + + return cursor; +} + +function clampPosition(position: number, doc: ProseMirrorNode): number { + return Math.max(0, Math.min(position, doc.content.size)); +} + +function normalizeCaretPosition( + position: number, + doc: ProseMirrorNode +): number { + const clampedPosition = clampPosition(position, doc); + const $position = doc.resolve(clampedPosition); + + if ($position.parent.isTextblock) { + return clampedPosition; + } + + return Selection.near($position, -1).anchor; +} + +function getRemoteCursors( + room: LiveblocksProsemirrorRoom, + field: string, + previousCursors: readonly RemoteCursor[] = [] +): RemoteCursor[] { + const cursors: RemoteCursor[] = []; + + for (const other of room.getOthers()) { + const rawPresence: unknown = other.presence[LIVEBLOCKS_CARET_PRESENCE_KEY]; + if (!isCursorPresence(rawPresence) || rawPresence.field !== field) { + continue; + } + + const user = getCursorUser(rawPresence.user) ?? getCursorUser(other.info); + const previousCursor = previousCursors.find( + (cursor) => cursor.connectionId === other.connectionId + ); + const hasPresencePositionChanged = + previousCursor === undefined || + previousCursor.rawAnchor !== rawPresence.anchor || + previousCursor.rawHead !== rawPresence.head; + + cursors.push({ + anchor: hasPresencePositionChanged + ? rawPresence.anchor + : previousCursor.anchor, + connectionId: other.connectionId, + head: hasPresencePositionChanged ? rawPresence.head : previousCursor.head, + rawAnchor: rawPresence.anchor, + rawHead: rawPresence.head, + user, + }); + } + + return cursors; +} + +function buildDecorationsFromCursors( + cursors: readonly RemoteCursor[], + doc: ProseMirrorNode +): DecorationSet { + const decorations: Decoration[] = []; + + for (const cursor of cursors) { + const anchor = clampPosition(cursor.anchor, doc); + const head = clampPosition(cursor.head, doc); + const from = Math.min(anchor, head); + const to = Math.max(anchor, head); + const user = cursor.user; + const color = user?.color ?? "#0f83ff"; + + if (from !== to) { + decorations.push( + Decoration.inline(from, to, { + class: "collaboration-carets__selection", + style: `background-color: ${color}33`, + }) + ); + } + + decorations.push( + Decoration.widget( + normalizeCaretPosition(head, doc), + () => createCursorElement(user), + { + key: `liveblocks-caret-${cursor.connectionId}`, + side: -1, + } + ) + ); + } + + return DecorationSet.create(doc, decorations); +} + +export function createLiveblocksCollaborationCaretPlugin( + options: CollaborationCaretOptions, + storage: CollaborationCaretStorage +): Plugin { + const room = options.room; + if (room === undefined) { + throw new Error( + "[Liveblocks] The Liveblocks caret plugin requires a room." + ); + } + + let view: EditorView | undefined; + let unsubscribe: (() => void) | undefined; + + const updatePresence = (nextView: EditorView) => { + const { anchor, head } = nextView.state.selection; + room.updatePresence( + presencePatch({ + field: options.field, + anchor, + head, + user: options.user, + }) + ); + }; + + const updateDecorations = () => { + if (view === undefined) { + return; + } + + storage.users = room.getOthers().map((other) => { + const rawPresence: unknown = + other.presence[LIVEBLOCKS_CARET_PRESENCE_KEY]; + const cursorPresence = isCursorPresence(rawPresence) + ? rawPresence + : undefined; + + return { + clientId: other.connectionId, + ...(getCursorUser(cursorPresence?.user) ?? getCursorUser(other.info)), + }; + }); + + const previousCursors = + LIVEBLOCKS_CARET_PLUGIN_KEY.getState(view.state)?.cursors ?? []; + const cursors = getRemoteCursors(room, options.field, previousCursors); + + view.dispatch( + view.state.tr.setMeta(LIVEBLOCKS_CARET_PLUGIN_KEY, { + cursors, + }) + ); + }; + + return new Plugin({ + key: LIVEBLOCKS_CARET_PLUGIN_KEY, + state: { + init(_, state): CollaborationCaretPluginState { + return { + cursors: [], + decorations: DecorationSet.create(state.doc, []), + }; + }, + apply(tr, state): CollaborationCaretPluginState { + const meta = tr.getMeta(LIVEBLOCKS_CARET_PLUGIN_KEY) as + | { cursors: RemoteCursor[] } + | undefined; + + if (meta !== undefined) { + return { + cursors: meta.cursors, + decorations: buildDecorationsFromCursors(meta.cursors, tr.doc), + }; + } + + if (!tr.docChanged) { + return state; + } + + if (!tr.getMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY)) { + const cursors = state.cursors.map((cursor) => ({ + ...cursor, + anchor: tr.mapping.map(cursor.anchor, -1), + head: tr.mapping.map(cursor.head, -1), + })); + + return { + cursors, + decorations: buildDecorationsFromCursors(cursors, tr.doc), + }; + } + + // Remote presence can arrive before the matching remote document + // update. Keep the stored cursor positions and rebuild them against + // the new document so pre-arrived presence is no longer clamped to the + // old document size. + return { + cursors: state.cursors, + decorations: buildDecorationsFromCursors(state.cursors, tr.doc), + }; + }, + }, + props: { + decorations(state) { + return ( + LIVEBLOCKS_CARET_PLUGIN_KEY.getState(state)?.decorations ?? + DecorationSet.empty + ); + }, + }, + view(editorView) { + view = editorView; + updatePresence(editorView); + updateDecorations(); + unsubscribe = room.events.others.subscribe(updateDecorations); + + return { + update(nextView, prevState) { + view = nextView; + + if (!nextView.state.selection.eq(prevState.selection)) { + updatePresence(nextView); + } + }, + destroy() { + unsubscribe?.(); + unsubscribe = undefined; + view = undefined; + room.updatePresence({ [LIVEBLOCKS_CARET_PRESENCE_KEY]: null }); + }, + }; + }, + }); +} diff --git a/packages/liveblocks-prosemirror/src/history.ts b/packages/liveblocks-prosemirror/src/history.ts new file mode 100644 index 00000000000..fa4e82e6386 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/history.ts @@ -0,0 +1,165 @@ +import { kInternal } from "@liveblocks/core"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import { + type Selection, + type SelectionBookmark, + TextSelection, + type Transaction, +} from "prosemirror-state"; + +import { + buildLiveblocksTreeIndex, + findTextRangeAtPositionInDocument, +} from "./mapping"; +import type { LiveblocksProsemirrorNode } from "./schema"; + +type HistorySelectionTextPoint = { + encodedOffset: number; + localOffset: number; + nodeId: string; + version: number; +}; + +type HistorySelectionPoint = { + absolute: number; + text?: HistorySelectionTextPoint; +}; + +export type HistorySelectionSnapshot = { + anchor: HistorySelectionPoint; + bookmark: SelectionBookmark; + head: HistorySelectionPoint; + isTextSelection: boolean; +}; + +function capturePoint( + position: number, + doc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): HistorySelectionPoint { + const range = findTextRangeAtPositionInDocument(doc, liveRoot, position); + if (range === undefined) { + return { absolute: position }; + } + + const localOffset = + range.liveOffset + + Math.max(0, Math.min(position - range.from, range.to - range.from)); + + return { + absolute: position, + text: { + encodedOffset: range.text[kInternal].encodeIndex(localOffset), + localOffset, + nodeId: range.nodeId, + version: range.text.version, + }, + }; +} + +export function captureHistorySelection( + selection: Selection, + doc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): HistorySelectionSnapshot { + return { + anchor: capturePoint(selection.anchor, doc, liveRoot), + bookmark: selection.getBookmark(), + head: capturePoint(selection.head, doc, liveRoot), + isTextSelection: selection instanceof TextSelection, + }; +} + +function clampPosition(position: number, doc: ProseMirrorNode): number { + return Math.max(0, Math.min(position, doc.content.size)); +} + +function resolveTextOffset( + point: HistorySelectionPoint, + doc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode, + preferLocalOffset: boolean +): number | undefined { + if (point.text === undefined) { + return undefined; + } + const textPoint = point.text; + + const index = buildLiveblocksTreeIndex(doc, liveRoot); + const ranges = index.textRanges.filter( + (range) => range.nodeId === textPoint.nodeId + ); + const text = ranges[0]?.text; + if (text === undefined) { + return undefined; + } + + const decodedOffset = preferLocalOffset + ? textPoint.localOffset + : text[kInternal].decodeIndex(textPoint.encodedOffset, textPoint.version); + if (decodedOffset === null) { + return undefined; + } + + const offset = Math.max(0, Math.min(decodedOffset, text.length)); + const containingRange = ranges.find( + (range) => + offset >= range.liveOffset && + offset <= range.liveOffset + (range.to - range.from) + ); + const range = containingRange ?? ranges.at(-1); + if (range === undefined) { + return undefined; + } + + return clampPosition( + range.from + + Math.max(0, Math.min(offset - range.liveOffset, range.to - range.from)), + doc + ); +} + +function resolvePoint( + point: HistorySelectionPoint, + doc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode, + preferLocalOffset: boolean +): number { + return ( + resolveTextOffset(point, doc, liveRoot, preferLocalOffset) ?? + clampPosition(point.absolute, doc) + ); +} + +export function restoreHistorySelection( + tr: Transaction, + liveRoot: LiveblocksProsemirrorNode, + snapshot: HistorySelectionSnapshot, + action: "undo" | "redo" +): Transaction { + if (!snapshot.isTextSelection) { + try { + return tr.setSelection(snapshot.bookmark.resolve(tr.doc)); + } catch { + return tr; + } + } + + // LiveText.decodeIndex intentionally maps a position at an insertion to its + // right edge. When undo restores a deleted range, that would collapse both + // endpoints to the end of the restored text. The local offsets describe the + // document after the inverse has been applied, so they preserve that range. + const preferLocalOffset = + action === "undo" && snapshot.anchor.absolute !== snapshot.head.absolute; + const anchor = resolvePoint( + snapshot.anchor, + tr.doc, + liveRoot, + preferLocalOffset + ); + const head = resolvePoint(snapshot.head, tr.doc, liveRoot, preferLocalOffset); + + return tr.setSelection( + TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head)) + ); +} diff --git a/packages/liveblocks-prosemirror/src/index.ts b/packages/liveblocks-prosemirror/src/index.ts new file mode 100644 index 00000000000..27452a5a365 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/index.ts @@ -0,0 +1,40 @@ +import { detectDupes } from "@liveblocks/core"; + +import { PKG_FORMAT, PKG_NAME, PKG_VERSION } from "./version"; + +detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); + +export type { + CollaborationCaretOptions, + CollaborationCaretPluginState, + CollaborationCaretStorage, + CursorUser, + RemoteCursor, +} from "./cursors"; +export { + createLiveblocksCollaborationCaretPlugin, + getCursorUser, + LIVEBLOCKS_CARET_PLUGIN_KEY, + LIVEBLOCKS_CARET_PRESENCE_KEY, + presencePatch, +} from "./cursors"; +export type { LiveblocksCollaborationOptions } from "./plugin"; +export { + createLiveblocksCollaborationPlugin, + getLiveblocksProsemirrorDocument, + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, +} from "./plugin"; +export type { + LiveblocksProsemirrorNode, + ProseMirrorJsonMark, + ProseMirrorJsonNode, +} from "./schema"; +export { + createLiveblocksProsemirrorNode, + getLiveblocksNodeContent, + getLiveblocksNodeId, + getLiveblocksNodeText, + liveblocksProsemirrorNodeToJson, + liveblocksProsemirrorNodeToJsonNodes, +} from "./schema"; +export type { LiveblocksProsemirrorRoom } from "./types"; diff --git a/packages/liveblocks-prosemirror/src/mapping.ts b/packages/liveblocks-prosemirror/src/mapping.ts new file mode 100644 index 00000000000..806b8763c07 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/mapping.ts @@ -0,0 +1,335 @@ +import type { LiveList, LiveText } from "@liveblocks/client"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import type { Selection } from "prosemirror-state"; + +import { + getLiveblocksNodeContent, + getLiveblocksNodeId, + getLiveblocksNodeText, + getLiveblocksNodeType, + type LiveblocksProsemirrorNode, +} from "./schema"; + +export type LiveblocksProsemirrorPosition = { + anchor: number; + head: number; +}; + +export function selectionToLiveblocksPosition( + selection: Selection +): LiveblocksProsemirrorPosition { + return { + anchor: selection.anchor, + head: selection.head, + }; +} + +export function clampLiveblocksPosition( + position: LiveblocksProsemirrorPosition, + max: number +): LiveblocksProsemirrorPosition { + return { + anchor: Math.max(0, Math.min(position.anchor, max)), + head: Math.max(0, Math.min(position.head, max)), + }; +} + +export type LiveblocksTextRange = { + from: number; + to: number; + liveOffset: number; + node: LiveblocksProsemirrorNode; + nodeId: string; + text: LiveText; +}; + +export type LiveblocksNodeRange = { + childIndex?: number; + content?: LiveList; + from: number; + node: LiveblocksProsemirrorNode; + nodeId: string; + parent?: LiveblocksProsemirrorNode; + pmNode: ProseMirrorNode; + to: number; +}; + +export type LiveblocksListRange = { + content: LiveList; + from: number; + node: LiveblocksProsemirrorNode; + nodeId: string; + pmNode: ProseMirrorNode; + to: number; +}; + +export type LiveblocksTreeIndex = { + listRanges: LiveblocksListRange[]; + nodeRanges: LiveblocksNodeRange[]; + textRanges: LiveblocksTextRange[]; +}; + +function childStart(parent: ProseMirrorNode, parentPos: number): number { + return parent.type.name === "doc" ? parentPos : parentPos + 1; +} + +function indexChildren( + nodeRanges: LiveblocksNodeRange[], + listRanges: LiveblocksListRange[], + textRanges: LiveblocksTextRange[], + pmParent: ProseMirrorNode, + liveParent: LiveblocksProsemirrorNode, + parentPos: number +): void { + const liveContent = getLiveblocksNodeContent(liveParent); + if (liveContent === undefined) { + return; + } + + listRanges.push({ + content: liveContent, + from: parentPos, + node: liveParent, + nodeId: getLiveblocksNodeId(liveParent), + pmNode: pmParent, + to: parentPos + pmParent.nodeSize, + }); + + let pmChildIndex = 0; + let pmOffset = 0; + const start = childStart(pmParent, parentPos); + + for (let liveIndex = 0; liveIndex < liveContent.length; liveIndex++) { + const liveChild = liveContent.get(liveIndex); + const pmChild = pmParent.maybeChild(pmChildIndex); + if (liveChild === undefined || pmChild === null) { + return; + } + + const from = start + pmOffset; + const to = from + pmChild.nodeSize; + + nodeRanges.push({ + childIndex: liveIndex, + content: liveContent, + from, + node: liveChild, + nodeId: getLiveblocksNodeId(liveChild), + parent: liveParent, + pmNode: pmChild, + to, + }); + + if (getLiveblocksNodeType(liveChild) === "text") { + const text = getLiveblocksNodeText(liveChild); + if (text === undefined) { + return; + } + + let liveOffset = 0; + let remaining = text.length; + + while (remaining > 0) { + const textChild = pmParent.maybeChild(pmChildIndex); + if (textChild === null || !textChild.isText) { + return; + } + + const length = Math.min(remaining, textChild.nodeSize); + const textFrom = start + pmOffset; + + textRanges.push({ + from: textFrom, + to: textFrom + length, + liveOffset, + node: liveChild, + nodeId: getLiveblocksNodeId(liveChild), + text, + }); + + liveOffset += length; + remaining -= length; + pmOffset += textChild.nodeSize; + pmChildIndex++; + } + } else { + indexChildren( + nodeRanges, + listRanges, + textRanges, + pmChild, + liveChild, + from + ); + pmOffset += pmChild.nodeSize; + pmChildIndex++; + } + } +} + +export function buildLiveblocksTreeIndex( + pmDoc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): LiveblocksTreeIndex { + const nodeRanges: LiveblocksNodeRange[] = [ + { + from: 0, + node: liveRoot, + nodeId: getLiveblocksNodeId(liveRoot), + pmNode: pmDoc, + to: pmDoc.content.size, + }, + ]; + const listRanges: LiveblocksListRange[] = []; + const textRanges: LiveblocksTextRange[] = []; + indexChildren(nodeRanges, listRanges, textRanges, pmDoc, liveRoot, 0); + return { listRanges, nodeRanges, textRanges }; +} + +export function findTextRangeAtPosition( + index: LiveblocksTreeIndex, + position: number +): LiveblocksTextRange | undefined { + return index.textRanges.find( + (range) => position >= range.from && position <= range.to + ); +} + +function findTextRangeAtPositionInChildren( + pmParent: ProseMirrorNode, + liveParent: LiveblocksProsemirrorNode, + parentPos: number, + position: number +): LiveblocksTextRange | undefined { + const liveContent = getLiveblocksNodeContent(liveParent); + if (liveContent === undefined) { + return undefined; + } + + let pmChildIndex = 0; + let pmOffset = 0; + const start = childStart(pmParent, parentPos); + + for (let liveIndex = 0; liveIndex < liveContent.length; liveIndex++) { + const liveChild = liveContent.get(liveIndex); + const pmChild = pmParent.maybeChild(pmChildIndex); + if (liveChild === undefined || pmChild === null) { + return undefined; + } + + if (getLiveblocksNodeType(liveChild) === "text") { + const text = getLiveblocksNodeText(liveChild); + if (text === undefined) { + return undefined; + } + + let liveOffset = 0; + let remaining = text.length; + + while (remaining > 0) { + const textChild = pmParent.maybeChild(pmChildIndex); + if (textChild === null || !textChild.isText) { + return undefined; + } + + const length = Math.min(remaining, textChild.nodeSize); + const from = start + pmOffset; + const to = from + length; + + if (position >= from && position <= to) { + return { + from, + to, + liveOffset, + node: liveChild, + nodeId: getLiveblocksNodeId(liveChild), + text, + }; + } + + liveOffset += length; + remaining -= length; + pmOffset += textChild.nodeSize; + pmChildIndex++; + } + } else { + const from = start + pmOffset; + const to = from + pmChild.nodeSize; + + if (position >= from && position <= to) { + return findTextRangeAtPositionInChildren( + pmChild, + liveChild, + from, + position + ); + } + + pmOffset += pmChild.nodeSize; + pmChildIndex++; + } + } + + return undefined; +} + +export function findTextRangeAtPositionInDocument( + pmDoc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode, + position: number +): LiveblocksTextRange | undefined { + return findTextRangeAtPositionInChildren(pmDoc, liveRoot, 0, position); +} + +export function findTextRangesInRange( + index: LiveblocksTreeIndex, + from: number, + to: number +): LiveblocksTextRange[] { + return index.textRanges.filter( + (range) => Math.max(range.from, from) < Math.min(range.to, to) + ); +} + +export function findTextRangeByLiveText( + index: LiveblocksTreeIndex, + text: LiveText +): LiveblocksTextRange | undefined { + return index.textRanges.find((range) => range.text === text); +} + +export function findNodeRangeByLiveNode( + index: LiveblocksTreeIndex, + node: LiveblocksProsemirrorNode +): LiveblocksNodeRange | undefined { + return index.nodeRanges.find((range) => range.node === node); +} + +export function findListRangeByLiveList( + index: LiveblocksTreeIndex, + content: unknown +): LiveblocksListRange | undefined { + return index.listRanges.find((range) => range.content === content); +} + +export function getChildPosition( + parent: ProseMirrorNode, + parentPos: number, + index: number +): number | undefined { + if (index < 0 || index > parent.childCount) { + return undefined; + } + + let offset = 0; + for (let childIndex = 0; childIndex < index; childIndex++) { + const child = parent.maybeChild(childIndex); + if (child === null) { + return undefined; + } + + offset += child.nodeSize; + } + + return childStart(parent, parentPos) + offset; +} diff --git a/packages/liveblocks-prosemirror/src/plugin.ts b/packages/liveblocks-prosemirror/src/plugin.ts new file mode 100644 index 00000000000..39e66076feb --- /dev/null +++ b/packages/liveblocks-prosemirror/src/plugin.ts @@ -0,0 +1,538 @@ +import type { LsonObject, StorageUpdate } from "@liveblocks/client"; +import { LiveMap, LiveObject } from "@liveblocks/client"; +import { kInternal } from "@liveblocks/core"; +import { Slice } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +import { + captureHistorySelection, + type HistorySelectionSnapshot, + restoreHistorySelection, +} from "./history"; +import { applyRemoteStorageUpdates } from "./remote"; +import { + createLiveblocksProsemirrorNode, + type LiveblocksProsemirrorNode, + liveblocksProsemirrorNodeToJson, + type ProseMirrorJsonNode, + stringifyDocument, +} from "./schema"; +import { applyIncrementalOperations, classifyTransaction } from "./steps"; +import type { LiveblocksProsemirrorRoom } from "./types"; + +export const LIVEBLOCKS_COLLABORATION_PLUGIN_KEY = new PluginKey<{ + isReady: boolean; +}>("liveblocks-collaboration"); +export const LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY = "_tiptap_docs"; + +/** Matches ProseMirror's default delay for grouping local edits. */ +const HISTORY_CAPTURE_TIMEOUT_MS = 500; + +export type LiveblocksCollaborationOptions = { + room?: LiveblocksProsemirrorRoom; + field: string; + initialContent?: ProseMirrorJsonNode; + fallbackDocument?: () => ProseMirrorJsonNode; +}; + +function isProseMirrorJsonNode(value: unknown): value is ProseMirrorJsonNode { + return ( + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" + ); +} + +function getInitialDocument( + initialContent: ProseMirrorJsonNode | undefined, + fallbackDocument: (() => ProseMirrorJsonNode) | undefined, + view: EditorView +): ProseMirrorJsonNode { + if (isProseMirrorJsonNode(initialContent)) { + return initialContent; + } + + const currentDocument: unknown = view.state.doc.toJSON(); + if (isProseMirrorJsonNode(currentDocument)) { + return currentDocument; + } + + const fallback = fallbackDocument?.(); + if (isProseMirrorJsonNode(fallback)) { + return fallback; + } + + throw new Error( + "[Liveblocks] The Liveblocks collaboration plugin could not resolve an initial document." + ); +} + +function replaceEditorDocument( + view: EditorView, + document: ProseMirrorJsonNode, + fallbackDocument: (() => ProseMirrorJsonNode) | undefined, + historyRestore?: { + action: "undo" | "redo"; + root: LiveblocksProsemirrorNode; + snapshot: HistorySelectionSnapshot; + } +): void { + let nextDocument; + try { + nextDocument = view.state.schema.nodeFromJSON(document); + } catch { + const fallback = fallbackDocument?.(); + if (!isProseMirrorJsonNode(fallback)) { + return; + } + + nextDocument = view.state.schema.nodeFromJSON(fallback); + } + + if (nextDocument.childCount === 0) { + const fallback = fallbackDocument?.(); + if (!isProseMirrorJsonNode(fallback)) { + return; + } + + nextDocument = view.state.schema.nodeFromJSON(fallback); + } + + let tr = view.state.tr.replace( + 0, + view.state.doc.content.size, + new Slice(nextDocument.content, 0, 0) + ); + + if (historyRestore !== undefined) { + tr = restoreHistorySelection( + tr, + historyRestore.root, + historyRestore.snapshot, + historyRestore.action + ); + } + + tr.setMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, { isRemote: true }).setMeta( + "addToHistory", + false + ); + + view.dispatch(tr); +} + +function getHistoryAction( + updates: readonly StorageUpdate[] | undefined +): "undo" | "redo" | undefined { + for (const update of updates ?? []) { + const source = update.source; + if ( + source.origin === "local" && + (source.via === "undo" || source.via === "redo") + ) { + return source.via; + } + } + + return undefined; +} + +export function getLiveblocksProsemirrorDocument( + root: LiveObject, + field: string +): LiveblocksProsemirrorNode | undefined { + const documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + if (!(documents instanceof LiveMap)) { + return undefined; + } + + const documentRoot = documents.get(field); + if (!(documentRoot instanceof LiveObject)) { + return undefined; + } + + return documentRoot as LiveblocksProsemirrorNode; +} + +function setDocumentRoot( + root: LiveObject, + field: string, + document: ProseMirrorJsonNode +): void { + let documents = root.get(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY); + if (!(documents instanceof LiveMap)) { + documents = new LiveMap(); + root.set(LIVEBLOCKS_TIPTAP_DOCUMENTS_KEY, documents); + } + + documents.set(field, createLiveblocksProsemirrorNode(document)); +} + +export function createLiveblocksCollaborationPlugin( + options: LiveblocksCollaborationOptions +): Plugin { + const room = options.room; + if (room === undefined) { + throw new Error( + "[Liveblocks] The Liveblocks collaboration plugin requires a room." + ); + } + + let view: EditorView | undefined; + let root: LiveObject | undefined; + let unsubscribe: (() => void) | undefined; + let unsubscribeFromHistory: (() => void) | undefined; + let isApplyingRemoteUpdate = false; + let isApplyingLocalUpdate = false; + let lastDocument = ""; + let historyCaptureTimer: ReturnType | undefined; + let captureBefore: HistorySelectionSnapshot | undefined; + let captureAfter: HistorySelectionSnapshot | undefined; + let pendingRestore: + | { + action: "undo" | "redo"; + snapshot: HistorySelectionSnapshot; + } + | undefined; + const selectionsByHistoryId = new Map< + number, + { + before: HistorySelectionSnapshot; + after: HistorySelectionSnapshot; + } + >(); + const privateHistory = room[kInternal]?.history; + + const commitHistoryCapture = () => { + if (historyCaptureTimer === undefined) { + return; + } + + clearTimeout(historyCaptureTimer); + historyCaptureTimer = undefined; + room.history.resume(); + }; + + const scheduleHistoryCaptureCommit = () => { + if (historyCaptureTimer !== undefined) { + clearTimeout(historyCaptureTimer); + } + + historyCaptureTimer = setTimeout(() => { + historyCaptureTimer = undefined; + room.history.resume(); + }, HISTORY_CAPTURE_TIMEOUT_MS); + }; + + const abortHistoryCapture = () => { + if (historyCaptureTimer !== undefined) { + clearTimeout(historyCaptureTimer); + historyCaptureTimer = undefined; + } + captureBefore = undefined; + captureAfter = undefined; + room.history.resume(); + }; + + const applyStorageToEditor = (updates?: StorageUpdate[]) => { + if (view === undefined || root === undefined) { + return; + } + + const documentRoot = getLiveblocksProsemirrorDocument(root, options.field); + if (documentRoot === undefined) { + return; + } + + if (isApplyingLocalUpdate) { + return; + } + + const document = liveblocksProsemirrorNodeToJson( + documentRoot, + options.fallbackDocument + ); + const serializedDocument = stringifyDocument(document); + const historyAction = getHistoryAction(updates); + const historyRestore = + historyAction !== undefined && pendingRestore?.action === historyAction + ? pendingRestore + : undefined; + if (historyAction !== undefined) { + pendingRestore = undefined; + } + + if (serializedDocument === lastDocument) { + if (historyRestore !== undefined) { + const tr = restoreHistorySelection( + view.state.tr, + documentRoot, + historyRestore.snapshot, + historyRestore.action + ) + .setMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, { isRemote: true }) + .setMeta("addToHistory", false); + if (!tr.selection.eq(view.state.selection)) { + view.dispatch(tr); + } + } + return; + } + + if (updates !== undefined) { + const result = applyRemoteStorageUpdates(view, documentRoot, updates); + if (result.type === "applied") { + lastDocument = serializedDocument; + isApplyingRemoteUpdate = true; + try { + const tr = + historyRestore === undefined + ? result.tr + : restoreHistorySelection( + result.tr, + documentRoot, + historyRestore.snapshot, + historyRestore.action + ); + view.dispatch( + tr + .setMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, { isRemote: true }) + .setMeta("addToHistory", false) + ); + } finally { + isApplyingRemoteUpdate = false; + } + return; + } + } + + lastDocument = serializedDocument; + isApplyingRemoteUpdate = true; + try { + replaceEditorDocument( + view, + document, + options.fallbackDocument, + historyRestore === undefined + ? undefined + : { + ...historyRestore, + root: documentRoot, + } + ); + } finally { + isApplyingRemoteUpdate = false; + } + }; + + return new Plugin({ + key: LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, + state: { + init: () => ({ isReady: false }), + apply(tr, state) { + const meta = tr.getMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY) as + | { isReady?: boolean } + | undefined; + + return meta?.isReady !== undefined + ? { ...state, isReady: meta.isReady } + : state; + }, + }, + appendTransaction(transactions, oldState, newState) { + if ( + root === undefined || + isApplyingRemoteUpdate || + transactions.some((transaction) => + Boolean(transaction.getMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY)) + ) + ) { + return null; + } + + if (!transactions.some((transaction) => transaction.docChanged)) { + if (transactions.some((transaction) => transaction.selectionSet)) { + commitHistoryCapture(); + } + return null; + } + + const currentRoot = root; + + const documentRoot = getLiveblocksProsemirrorDocument( + currentRoot, + options.field + ); + + // Idempotency guard: if the incoming document already matches what we + // last synced to storage, there is nothing to do. This naturally handles + // editors (e.g. BlockNote) that invoke appendTransaction more than once + // per user edit with the same transaction set + const incomingDocument: unknown = newState.doc.toJSON(); + if (!isProseMirrorJsonNode(incomingDocument)) { + return null; + } + const serializedIncoming = stringifyDocument(incomingDocument); + if (serializedIncoming === lastDocument) { + return null; + } + + if ( + privateHistory !== undefined && + captureBefore === undefined && + documentRoot !== undefined + ) { + captureBefore = captureHistorySelection( + oldState.selection, + oldState.doc, + documentRoot + ); + } + + const classified = + documentRoot !== undefined + ? classifyTransaction( + transactions, + oldState.doc, + newState.doc, + documentRoot + ) + : { type: "unsupported" as const }; + + isApplyingLocalUpdate = true; + try { + room.history.pause(); + try { + room.batch(() => { + if (classified.type === "incremental") { + applyIncrementalOperations(classified.operations); + } else { + setDocumentRoot(currentRoot, options.field, incomingDocument); + } + }); + } catch (error) { + abortHistoryCapture(); + throw error; + } + scheduleHistoryCaptureCommit(); + lastDocument = serializedIncoming; + + const updatedDocumentRoot = getLiveblocksProsemirrorDocument( + currentRoot, + options.field + ); + if (updatedDocumentRoot !== undefined) { + captureAfter = captureHistorySelection( + newState.selection, + newState.doc, + updatedDocumentRoot + ); + } + } finally { + isApplyingLocalUpdate = false; + } + + return null; + }, + view(editorView) { + view = editorView; + let destroyed = false; + + unsubscribeFromHistory = privateHistory?.subscribe((event) => { + if (event.action === "push") { + if (captureBefore !== undefined && captureAfter !== undefined) { + selectionsByHistoryId.set(event.id, { + before: captureBefore, + after: captureAfter, + }); + } + captureBefore = undefined; + captureAfter = undefined; + } else if (event.action === "undo" || event.action === "redo") { + const selections = selectionsByHistoryId.get(event.id); + const snapshot = + event.action === "undo" ? selections?.before : selections?.after; + pendingRestore = + snapshot === undefined + ? undefined + : { action: event.action, snapshot }; + } else if (event.action === "discard") { + for (const id of event.ids) { + selectionsByHistoryId.delete(id); + } + } else { + selectionsByHistoryId.clear(); + pendingRestore = undefined; + } + }); + + room.getStorage().then(({ root: storageRoot }) => { + if (destroyed) { + return; + } + + root = storageRoot; + + if ( + getLiveblocksProsemirrorDocument(storageRoot, options.field) === + undefined + ) { + const initialDocument = getInitialDocument( + options.initialContent, + options.fallbackDocument, + editorView + ); + room.history.disable(() => { + setDocumentRoot(storageRoot, options.field, initialDocument); + }); + } + + applyStorageToEditor(); + + const tr = editorView.state.tr.setMeta( + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, + { isReady: true } + ); + editorView.dispatch(tr); + + unsubscribe = room.subscribe( + storageRoot, + (updates) => { + if ( + updates.every((update) => { + const source = update.source; + return source.origin === "local" && source.via === "edit"; + }) + ) { + return; + } + + applyStorageToEditor(updates); + }, + { + isDeep: true, + } + ); + }); + + return { + update(nextView) { + view = nextView; + }, + destroy() { + destroyed = true; + commitHistoryCapture(); + unsubscribe?.(); + unsubscribeFromHistory?.(); + unsubscribe = undefined; + unsubscribeFromHistory = undefined; + view = undefined; + root = undefined; + selectionsByHistoryId.clear(); + pendingRestore = undefined; + }, + }; + }, + }); +} diff --git a/packages/liveblocks-prosemirror/src/remote.ts b/packages/liveblocks-prosemirror/src/remote.ts new file mode 100644 index 00000000000..a6fdbbc3673 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/remote.ts @@ -0,0 +1,400 @@ +import { + type Json, + LiveObject, + LiveText, + type StorageUpdate, +} from "@liveblocks/client"; +import { Fragment, type MarkType, type Schema, Slice } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +import { + buildLiveblocksTreeIndex, + findListRangeByLiveList, + findNodeRangeByLiveNode, + findTextRangeByLiveText, + getChildPosition, +} from "./mapping"; +import { + attributesToMarks, + getLiveblocksNodeAttrs, + getLiveblocksNodeContent, + getLiveblocksNodeText, + type LiveblocksProsemirrorNode, + liveblocksProsemirrorNodeToJsonNodes, +} from "./schema"; + +type RemoteApplyResult = + | { + type: "applied"; + tr: Transaction; + } + | { + type: "unsupported"; + }; + +function getMarkType(schema: Schema, type: string): MarkType | undefined { + return schema.marks[type]; +} + +function isLiveblocksProsemirrorNode( + value: unknown +): value is LiveblocksProsemirrorNode { + return ( + value instanceof LiveObject && + typeof value.get("id") === "string" && + typeof value.get("type") === "string" + ); +} + +function createSliceFromLiveblocksNode( + schema: Schema, + node: LiveblocksProsemirrorNode +): Slice { + const nodes = liveblocksProsemirrorNodeToJsonNodes(node).map((jsonNode) => + schema.nodeFromJSON(jsonNode) + ); + + return new Slice(Fragment.fromArray(nodes), 0, 0); +} + +function applyMarksFromAttributes( + tr: Transaction, + schema: Schema, + from: number, + to: number, + attributes: Record +): void { + const marks = attributesToMarks({ ...attributes }); + + for (const mark of marks ?? []) { + const markType = getMarkType(schema, mark.type); + if (markType !== undefined) { + tr.addMark(from, to, markType.create(mark.attrs)); + } + } + + if (marks === undefined) { + tr.removeMark(from, to); + } +} + +function findNodeRangeForLiveText( + index: ReturnType, + text: LiveText +) { + return index.nodeRanges.find( + (range) => getLiveblocksNodeText(range.node) === text + ); +} + +function collectCoveredDescendants( + node: LiveblocksProsemirrorNode, + coveredNodes: Set +): void { + coveredNodes.add(node); + + const text = getLiveblocksNodeText(node); + if (text !== undefined) { + coveredNodes.add(text); + } + + const content = getLiveblocksNodeContent(node); + if (content === undefined) { + return; + } + + coveredNodes.add(content); + for (let index = 0; index < content.length; index++) { + const child = content.get(index); + if (child !== undefined) { + collectCoveredDescendants(child, coveredNodes); + } + } +} + +function getStructurallyCoveredNodes( + updates: readonly StorageUpdate[] +): Set { + const coveredNodes = new Set(); + + for (const update of updates) { + if (update.type !== "LiveList") { + continue; + } + + for (const change of update.updates) { + if ( + (change.type === "insert" || + change.type === "set" || + change.type === "move") && + isLiveblocksProsemirrorNode(change.item) + ) { + collectCoveredDescendants(change.item, coveredNodes); + } + } + } + + return coveredNodes; +} + +function getCurrentTextForLiveText( + doc: Transaction["doc"], + index: ReturnType, + text: LiveText +): string | undefined { + const range = findNodeRangeForLiveText(index, text); + if (range === undefined) { + return undefined; + } + + return doc.textBetween(range.from, range.to); +} + +function isTextAlreadyApplied( + doc: Transaction["doc"], + index: ReturnType, + update: Extract +): boolean { + if (update.updates.some((change) => change.type === "format")) { + return false; + } + + return ( + getCurrentTextForLiveText(doc, index, update.node) === + update.node.toString() + ); +} + +function isLiveblocksNodeAlreadyApplied( + range: NonNullable> +): boolean { + const [jsonNode] = liveblocksProsemirrorNodeToJsonNodes(range.node); + if (jsonNode === undefined) { + return false; + } + + return JSON.stringify(range.pmNode.toJSON()) === JSON.stringify(jsonNode); +} + +export function applyRemoteStorageUpdates( + view: EditorView, + liveRoot: LiveblocksProsemirrorNode, + updates: readonly StorageUpdate[] +): RemoteApplyResult { + if (updates.length === 0) { + return { type: "unsupported" }; + } + + let tr = view.state.tr; + const structurallyCoveredNodes = getStructurallyCoveredNodes(updates); + + for (const update of updates) { + if (structurallyCoveredNodes.has(update.node)) { + continue; + } + + if (update.type === "LiveText") { + if (!(update.node instanceof LiveText)) { + return { type: "unsupported" }; + } + + const initialIndex = buildLiveblocksTreeIndex(tr.doc, liveRoot); + if (isTextAlreadyApplied(tr.doc, initialIndex, update)) { + continue; + } + + for (const change of update.updates) { + const index = buildLiveblocksTreeIndex(tr.doc, liveRoot); + const range = findTextRangeByLiveText(index, update.node); + + if (range === undefined && change.type === "delete") { + const wrapperRange = findNodeRangeForLiveText(index, update.node); + if (wrapperRange !== undefined) { + const from = wrapperRange.from + change.index; + tr = tr.delete(from, from + change.length); + continue; + } + + return { type: "unsupported" }; + } + + if (range === undefined) { + return { type: "unsupported" }; + } + + const from = range.from + change.index - range.liveOffset; + + if (change.type === "insert") { + tr = tr.insertText(change.text, from); + if (change.attributes !== undefined) { + applyMarksFromAttributes( + tr, + view.state.schema, + from, + from + change.text.length, + change.attributes + ); + } + } else if (change.type === "delete") { + tr = tr.delete(from, from + change.length); + } else { + applyMarksFromAttributes( + tr, + view.state.schema, + from, + from + change.length, + change.attributes + ); + } + } + + continue; + } + + if (update.type === "LiveList") { + for (const change of update.updates) { + const index = buildLiveblocksTreeIndex(tr.doc, liveRoot); + const range = findListRangeByLiveList(index, update.node); + if (range === undefined) { + return { type: "unsupported" }; + } + + if (isLiveblocksNodeAlreadyApplied(range)) { + continue; + } + + if (change.type === "insert") { + if (!isLiveblocksProsemirrorNode(change.item)) { + return { type: "unsupported" }; + } + + const pos = getChildPosition(range.pmNode, range.from, change.index); + if (pos === undefined) { + return { type: "unsupported" }; + } + + tr = tr.replace( + pos, + pos, + createSliceFromLiveblocksNode(view.state.schema, change.item) + ); + } else if (change.type === "delete") { + const from = getChildPosition(range.pmNode, range.from, change.index); + const to = getChildPosition( + range.pmNode, + range.from, + change.index + 1 + ); + if (from === undefined || to === undefined) { + return { type: "unsupported" }; + } + + tr = tr.delete(from, to); + } else if (change.type === "set") { + if (!isLiveblocksProsemirrorNode(change.item)) { + return { type: "unsupported" }; + } + + const from = getChildPosition(range.pmNode, range.from, change.index); + const to = getChildPosition( + range.pmNode, + range.from, + change.index + 1 + ); + if (from === undefined || to === undefined) { + return { type: "unsupported" }; + } + + tr = tr.replace( + from, + to, + createSliceFromLiveblocksNode(view.state.schema, change.item) + ); + } else { + if (!isLiveblocksProsemirrorNode(change.item)) { + return { type: "unsupported" }; + } + + const from = getChildPosition( + range.pmNode, + range.from, + change.previousIndex + ); + const to = getChildPosition( + range.pmNode, + range.from, + change.previousIndex + 1 + ); + const rawInsertPos = getChildPosition( + range.pmNode, + range.from, + change.index > change.previousIndex + ? change.index + 1 + : change.index + ); + if ( + from === undefined || + to === undefined || + rawInsertPos === undefined + ) { + return { type: "unsupported" }; + } + + const slice = createSliceFromLiveblocksNode( + view.state.schema, + change.item + ); + tr = tr.delete(from, to); + const insertPos = tr.mapping.map(rawInsertPos, -1); + tr = tr.replace(insertPos, insertPos, slice); + } + } + + continue; + } + + if (update.type === "LiveObject") { + if (!isLiveblocksProsemirrorNode(update.node)) { + return { type: "unsupported" }; + } + + const attrUpdate = update.updates.attrs; + if (attrUpdate === undefined) { + return { type: "unsupported" }; + } + + const index = buildLiveblocksTreeIndex(tr.doc, liveRoot); + const range = findNodeRangeByLiveNode(index, update.node); + if (range === undefined || range.pmNode.type.name === "doc") { + return { type: "unsupported" }; + } + + tr = tr.setNodeMarkup( + range.from, + undefined, + attrUpdate.type === "delete" + ? null + : getLiveblocksNodeAttrs(update.node) + ); + + continue; + } + + return { type: "unsupported" }; + } + + return { type: "applied", tr }; +} + +export function applyRemoteLiveTextUpdates( + view: EditorView, + liveRoot: LiveblocksProsemirrorNode, + updates: readonly StorageUpdate[] +): RemoteApplyResult { + if (!updates.every((update) => update.type === "LiveText")) { + return { type: "unsupported" }; + } + + return applyRemoteStorageUpdates(view, liveRoot, updates); +} diff --git a/packages/liveblocks-prosemirror/src/schema.ts b/packages/liveblocks-prosemirror/src/schema.ts new file mode 100644 index 00000000000..78b7690da50 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/schema.ts @@ -0,0 +1,259 @@ +import { + type Json, + type JsonObject, + LiveList, + LiveMap, + LiveObject, + LiveText, + type LiveTextAttributes, + type LiveTextAttributesPatch, + nanoid, +} from "@liveblocks/client"; + +export const TEXT_MARKS_ATTRIBUTE = "__liveblocks_tiptap_marks"; + +export type ProseMirrorJsonNode = { + type: string; + attrs?: JsonObject; + content?: ProseMirrorJsonNode[]; + text?: string; + marks?: ProseMirrorJsonMark[]; +}; + +export type ProseMirrorJsonMark = { + type: string; + attrs?: JsonObject; +}; + +type LiveblocksProsemirrorNodeData = { + id: string; + type: string; + attrs?: LiveMap; + content?: LiveList; + text?: LiveText; +}; + +export type LiveblocksProsemirrorNode = + LiveObject; + +function serializeLiveblocksNodeAttrs( + attrs: LiveMap +): JsonObject { + const serialized: JsonObject = {}; + for (const [key, value] of attrs) { + serialized[key] = value; + } + return serialized; +} + +function createLiveblocksNodeAttrs(attrs: JsonObject): LiveMap { + const entries: [string, Json][] = []; + for (const [key, value] of Object.entries(attrs)) { + if (value !== undefined) { + entries.push([key, value]); + } + } + return new LiveMap(entries); +} + +function isJsonObject(value: Json | undefined): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function marksToAttributes( + marks: ProseMirrorJsonMark[] | undefined +): LiveTextAttributes | undefined { + if (marks === undefined || marks.length === 0) { + return undefined; + } + + return { + [TEXT_MARKS_ATTRIBUTE]: marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs !== undefined ? { attrs: mark.attrs } : {}), + })), + }; +} + +export function attributesToMarks( + attributes: JsonObject | undefined +): ProseMirrorJsonMark[] | undefined { + const rawMarks = attributes?.[TEXT_MARKS_ATTRIBUTE]; + + if (!Array.isArray(rawMarks)) { + return undefined; + } + + const marks: ProseMirrorJsonMark[] = []; + for (const rawMark of rawMarks) { + if (!isJsonObject(rawMark) || typeof rawMark.type !== "string") { + continue; + } + + marks.push({ + type: rawMark.type, + ...(isJsonObject(rawMark.attrs) ? { attrs: rawMark.attrs } : {}), + }); + } + + return marks.length > 0 ? marks : undefined; +} + +export function marksToAttributesPatch( + marks: ProseMirrorJsonMark[] | undefined +): LiveTextAttributesPatch { + const attributes = marksToAttributes(marks); + return { + [TEXT_MARKS_ATTRIBUTE]: + attributes?.[TEXT_MARKS_ATTRIBUTE] === undefined + ? null + : attributes[TEXT_MARKS_ATTRIBUTE], + }; +} + +export function createLiveblocksProsemirrorNode( + node: ProseMirrorJsonNode +): LiveblocksProsemirrorNode { + if (node.type === "text") { + const text = new LiveText(); + text.insert(0, node.text ?? "", marksToAttributes(node.marks)); + + return new LiveObject({ + id: nanoid(), + type: node.type, + text, + }); + } + + return new LiveObject({ + id: nanoid(), + type: node.type, + ...(node.attrs !== undefined + ? { attrs: createLiveblocksNodeAttrs(node.attrs) } + : {}), + content: new LiveList( + (node.content ?? []).map((child) => + createLiveblocksProsemirrorNode(child) + ) + ), + }); +} + +export function getLiveblocksNodeId(node: LiveblocksProsemirrorNode): string { + return node.get("id"); +} + +export function getLiveblocksNodeType(node: LiveblocksProsemirrorNode): string { + return node.get("type"); +} + +export function getLiveblocksNodeContent( + node: LiveblocksProsemirrorNode +): LiveList | undefined { + const content = node.get("content"); + return content instanceof LiveList ? content : undefined; +} + +export function getLiveblocksNodeText( + node: LiveblocksProsemirrorNode +): LiveText | undefined { + const text = node.get("text"); + return text instanceof LiveText ? text : undefined; +} + +export function getLiveblocksNodeAttrs( + node: LiveblocksProsemirrorNode +): JsonObject | undefined { + const attrs = node.get("attrs"); + return attrs === undefined ? undefined : serializeLiveblocksNodeAttrs(attrs); +} + +export function updateLiveblocksNodeAttrs( + node: LiveblocksProsemirrorNode, + attrs: JsonObject | undefined +): void { + if (attrs === undefined) { + node.delete("attrs"); + } else { + node.set("attrs", createLiveblocksNodeAttrs(attrs)); + } +} + +function liveTextToTextNodes(text: LiveText): ProseMirrorJsonNode[] { + const nodes: ProseMirrorJsonNode[] = []; + + for (const [segmentText, segmentAttributes] of text.toJSON()) { + if (segmentText.length === 0) { + continue; + } + + nodes.push({ + type: "text", + text: segmentText, + ...(segmentAttributes !== undefined + ? { marks: attributesToMarks(segmentAttributes) } + : {}), + }); + } + + return nodes; +} + +export function liveblocksProsemirrorNodeToJsonNodes( + node: LiveblocksProsemirrorNode +): ProseMirrorJsonNode[] { + const type = node.get("type"); + + if (type === "text") { + const text = node.get("text"); + return text instanceof LiveText ? liveTextToTextNodes(text) : []; + } + + const content = node.get("content"); + const attrs = getLiveblocksNodeAttrs(node); + const jsonNode: ProseMirrorJsonNode = { + type, + ...(attrs !== undefined ? { attrs } : {}), + }; + + if (content instanceof LiveList && content.length > 0) { + const children: ProseMirrorJsonNode[] = []; + + for (let index = 0; index < content.length; index++) { + const child = content.get(index); + if (child !== undefined) { + children.push(...liveblocksProsemirrorNodeToJsonNodes(child)); + } + } + + if (children.length > 0) { + jsonNode.content = children; + } + } + + return [jsonNode]; +} + +export function liveblocksProsemirrorNodeToJson( + node: LiveblocksProsemirrorNode, + fallbackDocument?: () => ProseMirrorJsonNode +): ProseMirrorJsonNode { + const [jsonNode] = liveblocksProsemirrorNodeToJsonNodes(node); + + if ( + jsonNode === undefined || + (jsonNode.type === "doc" && + (!Array.isArray(jsonNode.content) || jsonNode.content.length === 0)) + ) { + const fallback = fallbackDocument?.(); + if (fallback !== undefined) { + return fallback; + } + } + + return jsonNode ?? { type: node.get("type") }; +} + +export function stringifyDocument(node: ProseMirrorJsonNode): string { + return JSON.stringify(node); +} diff --git a/packages/liveblocks-prosemirror/src/steps.ts b/packages/liveblocks-prosemirror/src/steps.ts new file mode 100644 index 00000000000..0cc1820ff70 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/steps.ts @@ -0,0 +1,627 @@ +import { type JsonObject, type LiveList, LiveText } from "@liveblocks/client"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; + +import { + buildLiveblocksTreeIndex, + findTextRangeAtPosition, + findTextRangeAtPositionInDocument, + type LiveblocksTreeIndex, +} from "./mapping"; +import { + createLiveblocksProsemirrorNode, + getLiveblocksNodeContent, + type LiveblocksProsemirrorNode, + marksToAttributes, + marksToAttributesPatch, + type ProseMirrorJsonMark, + type ProseMirrorJsonNode, + updateLiveblocksNodeAttrs, +} from "./schema"; + +export type IncrementalOperation = + | { + type: "insert"; + text: string; + index: number; + attributes?: ReturnType; + node: LiveblocksProsemirrorNode; + } + | { + type: "delete"; + index: number; + length: number; + node: LiveblocksProsemirrorNode; + } + | { + type: "format"; + index: number; + length: number; + attributes: ReturnType; + node: LiveblocksProsemirrorNode; + } + | { + type: "insertNode"; + content: LiveList; + index: number; + node: LiveblocksProsemirrorNode; + } + | { + type: "deleteNode"; + content: LiveList; + index: number; + } + | { + type: "setNode"; + content: LiveList; + index: number; + node: LiveblocksProsemirrorNode; + } + | { + type: "updateAttrs"; + attrs: JsonObject | undefined; + node: LiveblocksProsemirrorNode; + }; + +export type ClassifiedTransaction = + | { + type: "incremental"; + operations: IncrementalOperation[]; + } + | { + type: "unsupported"; + }; + +type ReplaceStepJson = { + stepType: "replace"; + from: number; + to: number; + slice?: { + content?: unknown[]; + }; +}; + +type MarkStepJson = { + stepType: "addMark" | "removeMark"; + from: number; + to: number; +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseMarks(value: unknown): ProseMirrorJsonMark[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const marks: ProseMirrorJsonMark[] = []; + + for (const mark of value) { + if (!isObject(mark) || typeof mark.type !== "string") { + return undefined; + } + + marks.push({ + type: mark.type, + ...(isJsonObject(mark.attrs) ? { attrs: mark.attrs } : {}), + }); + } + + return marks.length > 0 ? marks : undefined; +} + +function parseProseMirrorJsonNode( + value: unknown +): ProseMirrorJsonNode | undefined { + if (!isObject(value) || typeof value.type !== "string") { + return undefined; + } + + const node: ProseMirrorJsonNode = { type: value.type }; + + if (isJsonObject(value.attrs)) { + node.attrs = value.attrs; + } + + if (typeof value.text === "string") { + node.text = value.text; + } + + const marks = parseMarks(value.marks); + if (marks !== undefined) { + node.marks = marks; + } + + if (Array.isArray(value.content)) { + const content: ProseMirrorJsonNode[] = []; + for (const child of value.content) { + const parsedChild = parseProseMirrorJsonNode(child); + if (parsedChild === undefined) { + return undefined; + } + + content.push(parsedChild); + } + + node.content = content; + } + + return node; +} + +function prosemirrorNodeToJson( + node: ProseMirrorNode +): ProseMirrorJsonNode | undefined { + return parseProseMirrorJsonNode(node.toJSON()); +} + +function getNodeAttrs(node: ProseMirrorNode): JsonObject | undefined { + return prosemirrorNodeToJson(node)?.attrs; +} + +function isReplaceStepJson(value: unknown): value is ReplaceStepJson { + return ( + isObject(value) && + value.stepType === "replace" && + typeof value.from === "number" && + typeof value.to === "number" + ); +} + +function isMarkStepJson(value: unknown): value is MarkStepJson { + return ( + isObject(value) && + (value.stepType === "addMark" || value.stepType === "removeMark") && + typeof value.from === "number" && + typeof value.to === "number" + ); +} + +function getTextContentFromSlice( + slice: ReplaceStepJson["slice"] +): { text: string; marks?: ProseMirrorJsonMark[] } | undefined { + const content = slice?.content; + if (content === undefined || content.length === 0) { + return { text: "" }; + } + + let text = ""; + let marks: ProseMirrorJsonMark[] | undefined; + + for (const node of content) { + if (!isObject(node) || node.type !== "text") { + return undefined; + } + + const nodeText = node.text; + if (typeof nodeText !== "string") { + return undefined; + } + + const nodeMarks = parseMarks(node.marks); + if (text.length === 0) { + marks = nodeMarks; + } else if ( + JSON.stringify(marks ?? []) !== JSON.stringify(nodeMarks ?? []) + ) { + return undefined; + } + + text += nodeText; + } + + return { text, marks }; +} + +function classifyReplaceStep( + step: ReplaceStepJson, + oldDoc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): IncrementalOperation[] | undefined { + const inserted = getTextContentFromSlice(step.slice); + if (inserted === undefined) { + return undefined; + } + + const operations: IncrementalOperation[] = []; + + if (step.from !== step.to) { + const fromRange = findTextRangeAtPositionInDocument( + oldDoc, + liveRoot, + step.from + ); + const toRange = findTextRangeAtPositionInDocument( + oldDoc, + liveRoot, + step.to + ); + + if ( + fromRange === undefined || + toRange === undefined || + fromRange.nodeId !== toRange.nodeId + ) { + return undefined; + } + + operations.push({ + type: "delete", + node: fromRange.node, + index: fromRange.liveOffset + step.from - fromRange.from, + length: step.to - step.from, + }); + } + + if (inserted.text.length > 0) { + const range = findTextRangeAtPositionInDocument( + oldDoc, + liveRoot, + step.from + ); + if (range === undefined) { + return undefined; + } + + operations.push({ + type: "insert", + node: range.node, + index: range.liveOffset + step.from - range.from, + text: inserted.text, + attributes: marksToAttributes(inserted.marks), + }); + } + + return operations.length > 0 ? operations : undefined; +} + +function marksFromNode( + node: ProseMirrorNode +): ProseMirrorJsonMark[] | undefined { + const marks = node.marks.map((mark) => { + const json: unknown = mark.toJSON(); + if (!isObject(json) || typeof json.type !== "string") { + return undefined; + } + + return { + type: json.type, + ...(isJsonObject(json.attrs) ? { attrs: json.attrs } : {}), + }; + }); + + const parsedMarks = marks.filter((mark) => mark !== undefined); + return parsedMarks.length > 0 ? parsedMarks : undefined; +} + +function classifyMarkStep( + step: MarkStepJson, + oldIndex: LiveblocksTreeIndex, + newDoc: ProseMirrorNode +): IncrementalOperation[] | undefined { + const operations: IncrementalOperation[] = []; + + newDoc.nodesBetween(step.from, step.to, (node, pos) => { + if (!node.isText || node.nodeSize === 0) { + return true; + } + + const from = Math.max(step.from, pos); + const to = Math.min(step.to, pos + node.nodeSize); + const range = findTextRangeAtPosition(oldIndex, from); + + if (range === undefined || to <= from) { + operations.length = 0; + return false; + } + + operations.push({ + type: "format", + node: range.node, + index: range.liveOffset + from - range.from, + length: to - from, + attributes: marksToAttributesPatch(marksFromNode(node)), + }); + + return true; + }); + + return operations.length > 0 ? operations : undefined; +} + +function createNodeOperation( + type: "insertNode" | "setNode", + content: LiveList, + index: number, + node: ProseMirrorNode +): IncrementalOperation | undefined { + const jsonNode = prosemirrorNodeToJson(node); + if (jsonNode === undefined) { + return undefined; + } + + return { + type, + content, + index, + node: createLiveblocksProsemirrorNode(jsonNode), + }; +} + +function classifyAttrsChange( + oldNode: ProseMirrorNode, + newNode: ProseMirrorNode, + liveNode: LiveblocksProsemirrorNode +): IncrementalOperation[] | undefined { + if ( + oldNode.isText || + oldNode.type !== newNode.type || + !oldNode.content.eq(newNode.content) || + oldNode.sameMarkup(newNode) + ) { + return undefined; + } + + return [ + { type: "updateAttrs", node: liveNode, attrs: getNodeAttrs(newNode) }, + ]; +} + +function findCommonPrefix( + oldParent: ProseMirrorNode, + newParent: ProseMirrorNode +): number { + const max = Math.min(oldParent.childCount, newParent.childCount); + let prefix = 0; + + while (prefix < max && oldParent.child(prefix).eq(newParent.child(prefix))) { + prefix++; + } + + return prefix; +} + +function findCommonSuffix( + oldParent: ProseMirrorNode, + newParent: ProseMirrorNode, + prefix: number +): number { + const max = Math.min( + oldParent.childCount - prefix, + newParent.childCount - prefix + ); + let suffix = 0; + + while ( + suffix < max && + oldParent + .child(oldParent.childCount - suffix - 1) + .eq(newParent.child(newParent.childCount - suffix - 1)) + ) { + suffix++; + } + + return suffix; +} + +function classifyChildrenChange( + oldParent: ProseMirrorNode, + newParent: ProseMirrorNode, + liveParent: LiveblocksProsemirrorNode +): IncrementalOperation[] | undefined { + const content = getLiveblocksNodeContent(liveParent); + if (content === undefined) { + return undefined; + } + + if (oldParent.childCount === newParent.childCount) { + const operations: IncrementalOperation[] = []; + + for (let index = 0; index < oldParent.childCount; index++) { + const oldChild = oldParent.child(index); + const newChild = newParent.child(index); + if (oldChild.eq(newChild)) { + continue; + } + + const liveChild = content.get(index); + if (liveChild === undefined) { + return undefined; + } + + const childOperations = + classifyAttrsChange(oldChild, newChild, liveChild) ?? + classifyNodeChange(oldChild, newChild, liveChild); + + if (childOperations !== undefined) { + operations.push(...childOperations); + continue; + } + + const operation = createNodeOperation( + "setNode", + content, + index, + newChild + ); + if (operation === undefined) { + return undefined; + } + + operations.push(operation); + } + + return operations.length > 0 ? operations : undefined; + } + + const prefix = findCommonPrefix(oldParent, newParent); + const suffix = findCommonSuffix(oldParent, newParent, prefix); + + if ( + oldParent.childCount + 1 === newParent.childCount && + prefix + suffix === oldParent.childCount + ) { + const operation = createNodeOperation( + "insertNode", + content, + prefix, + newParent.child(prefix) + ); + return operation === undefined ? undefined : [operation]; + } + + if ( + oldParent.childCount + 1 === newParent.childCount && + prefix + suffix + 1 === oldParent.childCount + ) { + const setOperation = createNodeOperation( + "setNode", + content, + prefix, + newParent.child(prefix) + ); + const insertOperation = createNodeOperation( + "insertNode", + content, + prefix + 1, + newParent.child(prefix + 1) + ); + + return setOperation === undefined || insertOperation === undefined + ? undefined + : [setOperation, insertOperation]; + } + + if ( + oldParent.childCount - 1 === newParent.childCount && + prefix + suffix === newParent.childCount + ) { + return [{ type: "deleteNode", content, index: prefix }]; + } + + if ( + oldParent.childCount - 1 === newParent.childCount && + prefix + suffix + 1 === newParent.childCount + ) { + const operation = createNodeOperation( + "setNode", + content, + prefix, + newParent.child(prefix) + ); + + return operation === undefined + ? undefined + : [operation, { type: "deleteNode", content, index: prefix + 1 }]; + } + + return undefined; +} + +function classifyNodeChange( + oldNode: ProseMirrorNode, + newNode: ProseMirrorNode, + liveNode: LiveblocksProsemirrorNode +): IncrementalOperation[] | undefined { + const attrOperations = classifyAttrsChange(oldNode, newNode, liveNode) ?? []; + const childOperations = + oldNode.type === newNode.type + ? classifyChildrenChange(oldNode, newNode, liveNode) + : undefined; + const operations = [...attrOperations, ...(childOperations ?? [])]; + + return operations.length > 0 ? operations : undefined; +} + +function classifyStructuralChange( + oldDoc: ProseMirrorNode, + newDoc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): IncrementalOperation[] | undefined { + return classifyNodeChange(oldDoc, newDoc, liveRoot); +} + +export function classifyTransaction( + transactions: readonly Transaction[], + oldDoc: ProseMirrorNode, + newDoc: ProseMirrorNode, + liveRoot: LiveblocksProsemirrorNode +): ClassifiedTransaction { + const changedTransactions = transactions.filter( + (transaction) => transaction.docChanged + ); + + if (changedTransactions.length !== 1) { + return { type: "unsupported" }; + } + + const [transaction] = changedTransactions; + if (transaction === undefined || transaction.steps.length !== 1) { + return { type: "unsupported" }; + } + + const [step] = transaction.steps; + const stepJson: unknown = step?.toJSON(); + + const operations = isReplaceStepJson(stepJson) + ? classifyReplaceStep(stepJson, oldDoc, liveRoot) + : isMarkStepJson(stepJson) + ? classifyMarkStep( + stepJson, + buildLiveblocksTreeIndex(oldDoc, liveRoot), + newDoc + ) + : undefined; + + const structuralOperations = + operations ?? classifyStructuralChange(oldDoc, newDoc, liveRoot); + + if (structuralOperations === undefined) { + return { type: "unsupported" }; + } + + return { type: "incremental", operations: structuralOperations }; +} + +export function applyIncrementalOperations( + operations: readonly IncrementalOperation[] +): void { + for (const operation of operations) { + if (operation.type === "insert") { + const text = operation.node.get("text"); + if (!(text instanceof LiveText)) { + continue; + } + + text.insert(operation.index, operation.text, operation.attributes); + } else if (operation.type === "delete") { + const text = operation.node.get("text"); + if (!(text instanceof LiveText)) { + continue; + } + + text.delete(operation.index, operation.length); + } else if (operation.type === "format") { + const text = operation.node.get("text"); + if (!(text instanceof LiveText)) { + continue; + } + + text.format(operation.index, operation.length, operation.attributes); + } else if (operation.type === "insertNode") { + operation.content.insert(operation.node, operation.index); + } else if (operation.type === "deleteNode") { + operation.content.delete(operation.index); + } else if (operation.type === "setNode") { + operation.content.set(operation.index, operation.node); + } else { + updateLiveblocksNodeAttrs(operation.node, operation.attrs); + } + } +} diff --git a/packages/liveblocks-prosemirror/src/styles/index.css b/packages/liveblocks-prosemirror/src/styles/index.css new file mode 100644 index 00000000000..5f8bddb68ef --- /dev/null +++ b/packages/liveblocks-prosemirror/src/styles/index.css @@ -0,0 +1,37 @@ +.collaboration-carets__selection { + border-radius: 2px; +} + +.collaboration-carets__caret { + position: relative; + word-break: normal; + pointer-events: none; +} + +.collaboration-carets__caret::after { + content: ""; + position: absolute; + inset-inline-start: -1px; + inset-block-start: -0.15em; + block-size: 1.3em; + border-inline-start-width: 2px; + border-inline-start-style: solid; + border-inline-start-color: inherit; +} + +.collaboration-carets__label { + position: absolute; + inset-inline-start: -1px; + inset-block-start: -1.4em; + padding: 2px 6px; + border-radius: 6px; + border-end-start-radius: 0; + color: #fff; + font-weight: 600; + font-style: normal; + font-size: 14px; + line-height: normal; + white-space: nowrap; + pointer-events: none; + user-select: none; +} diff --git a/packages/liveblocks-prosemirror/src/types.ts b/packages/liveblocks-prosemirror/src/types.ts new file mode 100644 index 00000000000..16316e842d4 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/types.ts @@ -0,0 +1,38 @@ +import type { + JsonObject, + LiveObject, + LsonObject, + StorageUpdate, +} from "@liveblocks/client"; +import type { kInternal, PrivateRoomApi } from "@liveblocks/core"; + +export type LiveblocksProsemirrorRoom = { + readonly [kInternal]?: Pick; + batch(callback: () => void): void; + getOthers(): readonly { + connectionId: number; + info?: JsonObject; + presence: JsonObject; + }[]; + getStorage(): Promise<{ root: LiveObject }>; + history: { + canUndo(): boolean; + canRedo(): boolean; + disable(callback: () => T): T; + pause(): void; + resume(): void; + undo(): void; + redo(): void; + }; + subscribe( + node: LiveObject, + callback: (updates: StorageUpdate[]) => void, + options: { isDeep: true } + ): () => void; + updatePresence(patch: JsonObject): void; + events: { + others: { + subscribe(callback: () => void): () => void; + }; + }; +}; diff --git a/packages/liveblocks-prosemirror/src/version.ts b/packages/liveblocks-prosemirror/src/version.ts new file mode 100644 index 00000000000..e46c88a3624 --- /dev/null +++ b/packages/liveblocks-prosemirror/src/version.ts @@ -0,0 +1,6 @@ +declare const __VERSION__: string; +declare const ROLLUP_FORMAT: string; + +export const PKG_NAME = "@liveblocks/prosemirror"; +export const PKG_VERSION = typeof __VERSION__ === "string" && __VERSION__; +export const PKG_FORMAT = typeof ROLLUP_FORMAT === "string" && ROLLUP_FORMAT; diff --git a/packages/liveblocks-prosemirror/styles.css.d.cts b/packages/liveblocks-prosemirror/styles.css.d.cts new file mode 100644 index 00000000000..727406fa6e1 --- /dev/null +++ b/packages/liveblocks-prosemirror/styles.css.d.cts @@ -0,0 +1 @@ +declare module "@liveblocks/prosemirror/styles.css"; diff --git a/packages/liveblocks-prosemirror/styles.css.d.ts b/packages/liveblocks-prosemirror/styles.css.d.ts new file mode 100644 index 00000000000..727406fa6e1 --- /dev/null +++ b/packages/liveblocks-prosemirror/styles.css.d.ts @@ -0,0 +1 @@ +declare module "@liveblocks/prosemirror/styles.css"; diff --git a/packages/liveblocks-prosemirror/tsconfig.json b/packages/liveblocks-prosemirror/tsconfig.json new file mode 100644 index 00000000000..0b34c89d543 --- /dev/null +++ b/packages/liveblocks-prosemirror/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../shared/tsconfig.common.json", + + "compilerOptions": { + "lib": ["dom", "es2022"], + "noUncheckedIndexedAccess": false + }, + "include": ["src", "rollup.config.js"] +} diff --git a/packages/liveblocks-prosemirror/vitest.config.ts b/packages/liveblocks-prosemirror/vitest.config.ts new file mode 100644 index 00000000000..25ed75d3a21 --- /dev/null +++ b/packages/liveblocks-prosemirror/vitest.config.ts @@ -0,0 +1,8 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "happy-dom", + include: ["src/**/*.test.[jt]s?(x)"], + }, +}); diff --git a/packages/liveblocks-python/README.md b/packages/liveblocks-python/README.md index 387bcdbea79..71a922e468e 100644 --- a/packages/liveblocks-python/README.md +++ b/packages/liveblocks-python/README.md @@ -480,7 +480,7 @@ client.delete_storage_document( Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any operation fails, the document is not changed and a 422 response with a helpful message is returned. -**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. +**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. LiveText is a leaf node: only the LiveText node itself is addressable, not fields under its serialized `data`. Use `replace` with a string or a LiveTextData array to replace the whole node, for example `/text` with `[["Hello"]]`; use `remove` on `/text` to remove the node. LiveText versioning is internal and is not part of this API. **Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be suitable for this endpoint. diff --git a/packages/liveblocks-python/README.mdx b/packages/liveblocks-python/README.mdx index a3e81ba7dbe..15128e66e2c 100644 --- a/packages/liveblocks-python/README.mdx +++ b/packages/liveblocks-python/README.mdx @@ -584,7 +584,7 @@ client.delete_storage_document( Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any operation fails, the document is not changed and a 422 response with a helpful message is returned. -**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. +**Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. LiveText is a leaf node: only the LiveText node itself is addressable, not fields under its serialized `data`. Use `replace` with a string or a LiveTextData array to replace the whole node, for example `/text` with `[["Hello"]]`; use `remove` on `/text` to remove the node. LiveText versioning is internal and is not part of this API. **Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be suitable for this endpoint. diff --git a/packages/liveblocks-python/liveblocks/client.py b/packages/liveblocks-python/liveblocks/client.py index ea8c6db5958..a49ae0a261e 100644 --- a/packages/liveblocks-python/liveblocks/client.py +++ b/packages/liveblocks-python/liveblocks/client.py @@ -780,7 +780,7 @@ def patch_storage_document( | TestJsonPatchOperation ], ) -> None: - """Apply JSON Patch to Storage + r"""Apply JSON Patch to Storage Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any @@ -788,7 +788,11 @@ def patch_storage_document( **Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in - `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. + `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. LiveText is + a leaf node: only the LiveText node itself is addressable, not fields under its serialized `data`. + Use `replace` with a string or a LiveTextData array to replace the whole node, for example `/text` + with `[[\"Hello\"]]`; use `remove` on `/text` to remove the node. LiveText versioning is internal + and is not part of this API. **Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be @@ -4358,7 +4362,7 @@ async def patch_storage_document( | TestJsonPatchOperation ], ) -> None: - """Apply JSON Patch to Storage + r"""Apply JSON Patch to Storage Applies a sequence of [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations to the room's Storage document, useful for modifying Storage. Operations are applied in order; if any @@ -4366,7 +4370,11 @@ async def patch_storage_document( **Paths and data types:** Be as specific as possible with your target path. Every parent in the chain of path segments must be a LiveObject, LiveList, or LiveMap. Complex nested objects passed in - `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. + `add` or `replace` operations are automatically converted to LiveObjects and LiveLists. LiveText is + a leaf node: only the LiveText node itself is addressable, not fields under its serialized `data`. + Use `replace` with a string or a LiveTextData array to replace the whole node, for example `/text` + with `[[\"Hello\"]]`; use `remove` on `/text` to remove the node. LiveText versioning is internal + and is not part of this API. **Performance:** For large Storage documents, applying a patch can be expensive because the full state is reconstructed on the server to apply the operations. Very large documents may not be diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 4e24754d913..02c3ff92bd1 100644 --- a/packages/liveblocks-react-blocknote/package.json +++ b/packages/liveblocks-react-blocknote/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-blocknote", - "version": "3.23.1", + "version": "3.24.0", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -39,6 +39,7 @@ "lint": "eslint src/; stylelint src/styles/", "lint:package": "publint --strict && attw --pack", "start": "pnpm run dev", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:ci": "vitest run --coverage", "test:watch": "vitest" diff --git a/packages/liveblocks-react-blocknote/src/BlockNoteLiveblocksExtension.ts b/packages/liveblocks-react-blocknote/src/BlockNoteLiveblocksExtension.ts index 03c7ffb7e5a..97ce212ca04 100644 --- a/packages/liveblocks-react-blocknote/src/BlockNoteLiveblocksExtension.ts +++ b/packages/liveblocks-react-blocknote/src/BlockNoteLiveblocksExtension.ts @@ -1,18 +1,24 @@ import { useLiveblocksExtension as useTipTapLiveblocksExtension } from "@liveblocks/react-tiptap"; import type { Mark } from "@tiptap/core"; -export type LiveblocksExtensionOptions = Parameters< - typeof useTipTapLiveblocksExtension ->[0]; +export type LiveblocksExtensionOptions = NonNullable< + Parameters[0] +>; + +type InternalLiveblocksExtensionOptions = LiveblocksExtensionOptions & { + mentionNodes: boolean; + textEditorType: "blocknote"; +}; export const useLiveblocksExtension = ( options: LiveblocksExtensionOptions = {} ) => { - const extension = useTipTapLiveblocksExtension({ + const tiptapOptions: InternalLiveblocksExtensionOptions = { ...options, - // @ts-expect-error - Hidden config option + mentionNodes: false, textEditorType: "blocknote", - }); + }; + const extension = useTipTapLiveblocksExtension(tiptapOptions); extension.config.extendMarkSchema = (mark: Mark) => { if (mark.name === "liveblocksCommentMark") { diff --git a/packages/liveblocks-react-blocknote/src/__tests__/index.test.ts b/packages/liveblocks-react-blocknote/src/__tests__/index.test.ts index 00d07fbb570..2794cfc4baa 100644 --- a/packages/liveblocks-react-blocknote/src/__tests__/index.test.ts +++ b/packages/liveblocks-react-blocknote/src/__tests__/index.test.ts @@ -1,8 +1,126 @@ -import { test } from "vitest"; +import { Extension } from "@tiptap/core"; +import { beforeEach, describe, expect, test, vi } from "vitest"; -import * as _ from ".."; +import type { LiveblocksExtensionOptions } from ".."; +import { useLiveblocksExtension } from "../BlockNoteLiveblocksExtension"; +import { withLiveblocksEditorOptions } from "../initialization/liveblocksEditorOptions"; +import { useCreateBlockNoteWithLiveblocks } from "../initialization/useCreateBlockNoteWithLiveblocks"; + +const mocks = vi.hoisted(() => { + return { + useCreateBlockNote: vi.fn(), + useTipTapLiveblocksExtension: vi.fn(), + }; +}); + +vi.mock("@blocknote/react", () => { + return { + useCreateBlockNote: mocks.useCreateBlockNote, + }; +}); + +vi.mock("@liveblocks/react-tiptap", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + useLiveblocksExtension: mocks.useTipTapLiveblocksExtension, + }; +}); + +describe("@liveblocks/react-blocknote", () => { + beforeEach(() => { + mocks.useCreateBlockNote.mockReset(); + mocks.useTipTapLiveblocksExtension.mockReset(); + mocks.useTipTapLiveblocksExtension.mockReturnValue({ + config: {}, + }); + }); + + test("exports a public LiveblocksExtensionOptions type", () => { + const options: LiveblocksExtensionOptions = { + collaborationMode: "liveblocks", + field: "document", + }; + + expect(options.collaborationMode).toBe("liveblocks"); + expect(options.field).toBe("document"); + }); + + test("forwards collaborationMode and injects the blocknote editor type", () => { + const extension = useLiveblocksExtension({ + collaborationMode: "liveblocks", + field: "document", + }); + + expect(mocks.useTipTapLiveblocksExtension).toHaveBeenCalledWith({ + collaborationMode: "liveblocks", + field: "document", + mentionNodes: false, + textEditorType: "blocknote", + }); + + // XXX: TipTap's extension type does not expose BlockNote's schema hook. + const typedExtension = extension as { + config: { + extendMarkSchema?: (mark: { name: string }) => Record; + }; + }; + + expect( + typedExtension.config.extendMarkSchema?.({ + name: "liveblocksCommentMark", + }) + ).toEqual({ + blocknoteIgnore: true, + }); + expect( + typedExtension.config.extendMarkSchema?.({ + name: "bold", + }) + ).toEqual({}); + }); + + test("keeps BlockNote history disabled in liveblocks mode", () => { + const liveblocksExtension = Extension.create({ + name: "liveblocksExtension", + }); + + const options = withLiveblocksEditorOptions( + liveblocksExtension, + { + disableExtensions: ["slashMenu"], + }, + { + collaborationMode: "liveblocks", + } + ); + + expect(options.disableExtensions).toEqual(["history", "slashMenu"]); + }); + + test("uses the caller-provided dependencies to create the BlockNote editor", () => { + const liveblocksExtension = Extension.create({ + name: "liveblocksExtension", + }); + mocks.useTipTapLiveblocksExtension.mockReturnValue(liveblocksExtension); + + const deps = ["room-id"]; + + useCreateBlockNoteWithLiveblocks( + {}, + { + collaborationMode: "liveblocks", + }, + deps + ); + + expect(mocks.useCreateBlockNote).toHaveBeenCalledTimes(1); + expect(mocks.useCreateBlockNote.mock.calls[0]?.[1]).toBe(deps); + }); +}); test.todo("Write test for _.FloatingComposer"); test.todo("Write test for _.FloatingThreads"); -test.todo("Write test for _.LiveblocksExtension"); test.todo("Write test for _.AnchoredThreads"); diff --git a/packages/liveblocks-react-blocknote/src/index.ts b/packages/liveblocks-react-blocknote/src/index.ts index 19f6464f340..0b6f9990b5b 100644 --- a/packages/liveblocks-react-blocknote/src/index.ts +++ b/packages/liveblocks-react-blocknote/src/index.ts @@ -4,6 +4,7 @@ import { PKG_FORMAT, PKG_NAME, PKG_VERSION } from "./version"; detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); +export type { LiveblocksExtensionOptions } from "./BlockNoteLiveblocksExtension"; export { useLiveblocksExtension } from "./BlockNoteLiveblocksExtension"; export { AnchoredThreads } from "./comments/AnchoredThreads"; export { FloatingComposer } from "./comments/FloatingComposer"; diff --git a/packages/liveblocks-react-blocknote/src/initialization/liveblocksEditorOptions.ts b/packages/liveblocks-react-blocknote/src/initialization/liveblocksEditorOptions.ts index 3b207c863f5..4327f7c48ca 100644 --- a/packages/liveblocks-react-blocknote/src/initialization/liveblocksEditorOptions.ts +++ b/packages/liveblocks-react-blocknote/src/initialization/liveblocksEditorOptions.ts @@ -10,6 +10,7 @@ import { } from "@blocknote/core"; import type { Extension } from "@tiptap/core"; +import type { LiveblocksExtensionOptions } from "../BlockNoteLiveblocksExtension"; import { withLiveblocksSchema } from "./schema"; /** * Helper function to add Liveblocks support to BlockNoteEditorOptions @@ -21,7 +22,7 @@ export const withLiveblocksEditorOptions = < >( liveblocksExtension: Extension, blocknoteOptions: Partial> = {}, - liveblocksOptions: Partial<{ mentions: boolean }> = {} + liveblocksOptions: LiveblocksExtensionOptions = {} ): Partial> => { const { schema: blocknoteSchema, diff --git a/packages/liveblocks-react-blocknote/src/initialization/useCreateBlockNoteWithLiveblocks.ts b/packages/liveblocks-react-blocknote/src/initialization/useCreateBlockNoteWithLiveblocks.ts index 7fd7bde53c7..7685540229c 100644 --- a/packages/liveblocks-react-blocknote/src/initialization/useCreateBlockNoteWithLiveblocks.ts +++ b/packages/liveblocks-react-blocknote/src/initialization/useCreateBlockNoteWithLiveblocks.ts @@ -23,7 +23,7 @@ export const useCreateBlockNoteWithLiveblocks = < S extends StyleSchema = DefaultStyleSchema, >( blocknoteOptions: Partial> = {}, - liveblocksOptions: LiveblocksExtensionOptions = undefined, + liveblocksOptions?: LiveblocksExtensionOptions, deps: DependencyList = [] ) => { const liveblocksExtension = useLiveblocksExtension(liveblocksOptions); @@ -33,6 +33,6 @@ export const useCreateBlockNoteWithLiveblocks = < blocknoteOptions, liveblocksOptions ), - [liveblocksExtension, ...deps] + deps ); }; diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json index d709afd4a29..c587e509fa2 100644 --- a/packages/liveblocks-react-flow/package.json +++ b/packages/liveblocks-react-flow/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-flow", - "version": "3.23.1", + "version": "3.24.0", "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -57,8 +57,9 @@ "format": "(eslint --fix src/ || true) && stylelint --fix src/styles/ && prettier --write src/", "lint:package": "publint --strict && attw --pack && node check-node-entrypoint.mjs", "lint": "eslint src/ && stylelint src/styles/", - "test": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", - "test:ci": "pnpm dlx liveblocks dev -P -c 'vitest run --coverage'", + "typecheck": "tsc --noEmit", + "test": "pnpm dlx liveblocks@pre dev -P -c 'vitest run --coverage'", + "test:ci": "pnpm dlx liveblocks@pre dev -P -c 'vitest run --coverage'", "test:types": "vitest run --config ./vitest.config.typecheck.ts", "test:watch": "vitest" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index 0536ce26418..6ba7e665943 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.23.1", + "version": "3.24.0", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -39,6 +39,7 @@ "lint": "eslint src/; stylelint src/styles/", "lint:package": "publint --strict && attw --pack", "start": "pnpm run dev", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:ci": "vitest run --coverage", "test:watch": "vitest" diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index dce91956fe3..11c02a1648b 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.23.1", + "version": "3.24.0", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -47,6 +47,7 @@ "@floating-ui/react-dom": "^2.1.0", "@liveblocks/client": "workspace:*", "@liveblocks/core": "workspace:*", + "@liveblocks/prosemirror": "workspace:*", "@liveblocks/react": "workspace:*", "@liveblocks/react-ui": "workspace:*", "@liveblocks/yjs": "workspace:*", diff --git a/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts b/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts index 77c3e0dc6aa..57ecb059ddb 100644 --- a/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts +++ b/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts @@ -27,6 +27,8 @@ import { CollaborationCaret, type CollaborationCaretOptions, } from "./collaboration-caret/collaboration-caret"; +import { LiveblocksCollaborationCaret } from "./collaboration-liveblocks/cursors"; +import { LiveblocksCollaboration } from "./collaboration-liveblocks/plugin"; import { CommentsExtension, FILTERED_THREADS_PLUGIN_KEY, @@ -44,6 +46,7 @@ import { areSetsEqual } from "./utils"; type WithRequired = T & { [P in K]-?: T[P] }; const DEFAULT_OPTIONS: WithRequired = { + collaborationMode: "yjs", field: "default", comments: true, mentions: true, @@ -189,15 +192,27 @@ export const useLiveblocksExtension = ( // } // }); - const isEditorReady = useIsEditorReady(); const client = useClient(); const store = getUmbrellaStoreForClient(client); const roomId = room.id; const yjsProvider = useYjsProvider(); + const isLiveblocksStorageMode = options.collaborationMode === "liveblocks"; + + // order matters here, this should happen before useIsEditorReady + let provider = + !isLiveblocksStorageMode && + getYjsProviderForRoom(room, { + enablePermanentUserData: !!options.ai || options.enablePermanentUserData, + offlineSupport_experimental: options.offlineSupport_experimental, + }); + + const isEditorReady = useIsEditorReady(); + const isReadyForSideEffects = isLiveblocksStorageMode || isEditorReady; // If the user provided initialContent, wait for ready and then set it useEffect(() => { if ( + isLiveblocksStorageMode || !isEditorReady || !yjsProvider || !options.initialContent || @@ -213,14 +228,19 @@ export const useLiveblocksExtension = ( ydoc.getMap("liveblocks_config").set("hasContentSet", true); editor.current.commands.setContent(options.initialContent); } - }, [isEditorReady, yjsProvider, options.initialContent]); + }, [ + isEditorReady, + isLiveblocksStorageMode, + yjsProvider, + options.initialContent, + ]); useReportTextEditor(textEditorType, options.field ?? DEFAULT_OPTIONS.field); const prevThreadsRef = useRef | undefined>(undefined); useEffect(() => { - if (!isEditorReady) return; + if (!isReadyForSideEffects) return; if (!editor.current) return; @@ -246,7 +266,7 @@ export const useLiveblocksExtension = ( }) ); } - }, [isEditorReady, options.threads_experimental]); + }, [isReadyForSideEffects, options.threads_experimental]); const createTextMention = useCreateTextMention(); const deleteTextMention = useDeleteTextMention(); @@ -257,7 +277,10 @@ export const useLiveblocksExtension = ( onCreate() { editor.current = this.editor; - if (this.editor.options.content) { + if ( + textEditorType !== TextEditorType.BlockNote && + this.editor.options.content + ) { console.warn( "[Liveblocks] Initial content must be set in the useLiveblocksExtension hook option. Remove content from your editor options." ); @@ -280,6 +303,16 @@ export const useLiveblocksExtension = ( if (!info) { return; } + if (this.storage.mode === "liveblocks") { + this.editor.commands.updateUser({ + name: info.name, + color: info.color, + }); + return; + } + + // y-prosemirror stores arbitrary user metadata in awareness, but its + // API does not expose a typed local-state shape. const { user: storedUser } = this.storage.provider.awareness.getLocalState() as { user: IUserInfo; @@ -377,12 +410,23 @@ export const useLiveblocksExtension = ( ]; }, addStorage() { - const provider = getYjsProviderForRoom(room, { + if (isLiveblocksStorageMode) { + return { + mode: "liveblocks", + field: options.field, + unsubs: [], + }; + } + + provider = getYjsProviderForRoom(room, { enablePermanentUserData: !!options.ai || options.enablePermanentUserData, offlineSupport_experimental: options.offlineSupport_experimental, }); + return { + mode: "yjs", + field: options.field, doc: provider.getYDoc(), provider, permanentUserData: provider.permanentUserData, @@ -390,20 +434,45 @@ export const useLiveblocksExtension = ( }; }, addExtensions() { - const extensions: AnyExtension[] = [ - YChangeMark, + const selfInfo = room.getSelf()?.info; + const selfUser = + selfInfo !== undefined + ? { + name: + typeof selfInfo.name === "string" ? selfInfo.name : undefined, + color: + typeof selfInfo.color === "string" ? selfInfo.color : undefined, + } + : undefined; - LiveblocksCollab.configure({ - ySyncOptions: { - permanentUserData: this.storage.permanentUserData, - }, - document: this.storage.doc, - field: options.field, - }), - CollaborationCaret.configure({ - provider: this.storage.provider, - }) as Extension, - ]; + const extensions: AnyExtension[] = + this.storage.mode === "liveblocks" + ? [ + LiveblocksCollaboration.configure({ + room, + field: options.field, + initialContent: options.initialContent, + }), + LiveblocksCollaborationCaret.configure({ + room, + field: options.field, + user: selfUser ?? {}, + }), + ] + : [ + YChangeMark, + + LiveblocksCollab.configure({ + ySyncOptions: { + permanentUserData: this.storage.permanentUserData, + }, + document: this.storage.doc, + field: options.field, + }), + CollaborationCaret.configure({ + provider: this.storage.provider, + }) as Extension, + ]; if (options.comments) { extensions.push(CommentsExtension); @@ -415,10 +484,18 @@ export const useLiveblocksExtension = ( createTextMention(mention.notificationId, mention); }, onDeleteMention: deleteTextMention, + mentionNodes: options.mentionNodes ?? true, }) ); } if (options.ai) { + if (this.storage.mode === "liveblocks") { + console.warn( + "[Liveblocks] AI in @liveblocks/react-tiptap currently requires the Yjs collaboration mode." + ); + return extensions; + } + const resolveContextualPrompt = async ({ prompt, context, diff --git a/packages/liveblocks-react-tiptap/src/__tests__/HistoryVersionPreview.test.tsx b/packages/liveblocks-react-tiptap/src/__tests__/HistoryVersionPreview.test.tsx new file mode 100644 index 00000000000..73a6afea360 --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/__tests__/HistoryVersionPreview.test.tsx @@ -0,0 +1,112 @@ +import type { HistoryVersion } from "@liveblocks/core"; +import type { Editor } from "@tiptap/react"; +import { createRoot } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + useHistoryVersionStorageData: vi.fn(() => ({ isLoading: true })), + useHistoryVersionYjsData: vi.fn(() => ({ isLoading: true })), +})); + +vi.mock("@liveblocks/react", () => ({ + useHistoryVersionStorageData: mocks.useHistoryVersionStorageData, + useHistoryVersionYjsData: mocks.useHistoryVersionYjsData, +})); + +vi.mock("@liveblocks/prosemirror", () => ({ + getLiveblocksProsemirrorDocument: vi.fn(), + liveblocksProsemirrorNodeToJson: vi.fn(), +})); + +vi.mock("@liveblocks/react-ui", () => ({ + useOverrides: () => ({ + HISTORY_VERSION_PREVIEW_AUTHORS_LIST: (value: unknown) => value, + HISTORY_VERSION_PREVIEW_ERROR: (error: Error) => error.message, + HISTORY_VERSION_PREVIEW_RESTORE: "Restore", + LIST_REMAINING_USERS: () => "", + locale: "en", + }), +})); + +vi.mock("@liveblocks/react-ui/_private", () => ({ + Button: "button", + List: "span", + RestoreIcon: "span", + SpinnerIcon: "span", + User: "span", + cn: (...values: unknown[]) => values.filter(Boolean).join(" "), +})); + +vi.mock("@tiptap/react", () => ({ + EditorContent: "div", + useEditor: () => null, +})); + +import { HistoryVersionPreview } from "../version-history/HistoryVersionPreview"; + +const version: HistoryVersion = { + id: "vh_test", + authors: [], + createdAt: new Date(), +}; + +function createEditor(mode: "liveblocks" | "yjs"): Editor { + // The component only reads these editor properties before its preview editor + // is created; constructing a real Tiptap editor would obscure the source test. + return { + extensionManager: { extensions: [] }, + storage: { + liveblocksExtension: { + mode, + field: "custom", + unsubs: [], + }, + }, + } as unknown as Editor; +} + +afterEach(() => { + mocks.useHistoryVersionStorageData.mockClear(); + mocks.useHistoryVersionYjsData.mockClear(); + document.body.innerHTML = ""; +}); + +describe("HistoryVersionPreview", () => { + test("loads Storage history in Liveblocks collaboration mode", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + ); + }); + + expect(mocks.useHistoryVersionStorageData).toHaveBeenCalledWith("vh_test"); + expect(mocks.useHistoryVersionYjsData).not.toHaveBeenCalled(); + + act(() => root.unmount()); + }); + + test("keeps loading Yjs history in Yjs collaboration mode", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + ); + }); + + expect(mocks.useHistoryVersionYjsData).toHaveBeenCalledWith("vh_test"); + expect(mocks.useHistoryVersionStorageData).not.toHaveBeenCalled(); + + act(() => root.unmount()); + }); +}); diff --git a/packages/liveblocks-react-tiptap/src/__tests__/collaboration-liveblocks.test.ts b/packages/liveblocks-react-tiptap/src/__tests__/collaboration-liveblocks.test.ts new file mode 100644 index 00000000000..7f676203906 --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/__tests__/collaboration-liveblocks.test.ts @@ -0,0 +1,199 @@ +import { type AnyExtension, Editor } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + createLiveblocksCollaborationPlugin: vi.fn(), + createLiveblocksCollaborationCaretPlugin: vi.fn(), + }; +}); + +vi.mock("@liveblocks/prosemirror", () => { + return { + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY: new PluginKey( + "liveblocks-collaboration" + ), + LIVEBLOCKS_CARET_PLUGIN_KEY: new PluginKey( + "liveblocks-collaboration-caret" + ), + createLiveblocksCollaborationPlugin: + mocks.createLiveblocksCollaborationPlugin, + createLiveblocksCollaborationCaretPlugin: + mocks.createLiveblocksCollaborationCaretPlugin, + getCursorUser(value: unknown) { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const user = value as { name?: unknown; color?: unknown }; + const name = typeof user.name === "string" ? user.name : undefined; + const color = typeof user.color === "string" ? user.color : undefined; + return name !== undefined || color !== undefined + ? { name, color } + : undefined; + }, + presencePatch({ + field, + anchor, + head, + user, + }: { + field: string; + anchor: number; + head: number; + user?: { name?: string; color?: string }; + }) { + return { + liveblocksTiptap: { + field, + anchor, + head, + ...(user !== undefined ? { user } : {}), + }, + }; + }, + }; +}); + +import { LiveblocksCollaborationCaret } from "../collaboration-liveblocks/cursors"; +import { LiveblocksCollaboration } from "../collaboration-liveblocks/plugin"; + +type CollaborationPluginOptions = { + fallbackDocument: () => unknown; +}; + +function createRoom() { + return { + batch(callback: () => void) { + callback(); + }, + getOthers: () => [], + getStorage: () => Promise.reject(new Error("Unexpected storage access")), + history: { + canUndo: vi.fn(() => true), + canRedo: vi.fn(() => true), + disable: (callback: () => T) => callback(), + pause: vi.fn(), + resume: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + }, + subscribe: vi.fn(() => () => {}), + updatePresence: vi.fn(), + events: { + others: { + subscribe: vi.fn(() => () => {}), + }, + }, + }; +} + +function createEditor(extensions: AnyExtension[]) { + return new Editor({ + extensions: [Document, Paragraph, Text, ...(extensions ?? [])], + content: "

    Hello

    ", + }); +} + +describe("Liveblocks ProseMirror Tiptap adapters", () => { + beforeEach(() => { + mocks.createLiveblocksCollaborationPlugin.mockReset(); + mocks.createLiveblocksCollaborationCaretPlugin.mockReset(); + mocks.createLiveblocksCollaborationPlugin.mockReturnValue(new Plugin({})); + mocks.createLiveblocksCollaborationCaretPlugin.mockReturnValue( + new Plugin({}) + ); + }); + + test("passes field, initialContent, and Tiptap fallback document to the collaboration plugin", () => { + const room = createRoom(); + const initialContent = { + type: "doc", + content: [{ type: "paragraph" }], + }; + const editor = createEditor([ + LiveblocksCollaboration.configure({ + room, + field: "custom", + initialContent, + }), + ]); + + expect(mocks.createLiveblocksCollaborationPlugin).toHaveBeenCalledWith({ + room, + field: "custom", + initialContent, + fallbackDocument: expect.any(Function), + }); + const options = mocks.createLiveblocksCollaborationPlugin.mock + .calls[0]?.[0] as CollaborationPluginOptions | undefined; + expect(options?.fallbackDocument()).toEqual({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + editor.destroy(); + }); + + test("keeps undo and redo wired to Liveblocks room history", () => { + const room = createRoom(); + const editor = createEditor([LiveblocksCollaboration.configure({ room })]); + + expect(editor.commands.undo()).toBe(true); + expect(room.history.resume).toHaveBeenCalledTimes(1); + expect(room.history.undo).toHaveBeenCalledTimes(1); + + expect(editor.commands.redo()).toBe(true); + expect(room.history.resume).toHaveBeenCalledTimes(2); + expect(room.history.redo).toHaveBeenCalledTimes(1); + + editor.destroy(); + }); + + test("passes caret options and storage to the extracted caret plugin", () => { + const room = createRoom(); + const editor = createEditor([ + LiveblocksCollaborationCaret.configure({ + room, + field: "custom", + user: { name: "Ada", color: "#f00" }, + }), + ]); + + expect(mocks.createLiveblocksCollaborationCaretPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + room, + field: "custom", + user: { name: "Ada", color: "#f00" }, + }), + expect.objectContaining({ users: [] }) + ); + + editor.destroy(); + }); + + test("keeps updateUser wired to Liveblocks presence", () => { + const room = createRoom(); + const editor = createEditor([ + LiveblocksCollaborationCaret.configure({ room }), + ]); + + expect(editor.commands.updateUser({ name: "Ada", color: "#f00" })).toBe( + true + ); + expect(room.updatePresence).toHaveBeenCalledWith({ + liveblocksTiptap: { + field: "default", + anchor: 1, + head: 1, + user: { name: "Ada", color: "#f00" }, + }, + }); + + editor.destroy(); + }); +}); diff --git a/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts b/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts index 8e3b7a5d91a..2e0d5690838 100644 --- a/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts +++ b/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts @@ -27,6 +27,7 @@ import { type AiExtensionOptions, type AiExtensionStorage, type AiToolbarState, + type LiveblocksExtensionStorage, type ResolveContextualPromptResponse, type YSyncPluginState, } from "../types"; @@ -46,8 +47,12 @@ function getYjsBinding(editor: Editor) { function getLiveblocksYjsProvider( editor: Editor ): LiveblocksYjsProvider | undefined { - // Eslint doesn't seem to like Tiptap's Type declaration strategy - return editor.extensionStorage.liveblocksExtension?.provider; + // Tiptap extension storage is exposed as untyped extension data here. + const storage = editor.extensionStorage.liveblocksExtension as + | LiveblocksExtensionStorage + | undefined; + + return storage?.mode === "yjs" ? storage.provider : undefined; } export function isContextualPromptDiffResponse( diff --git a/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/binding.ts b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/binding.ts new file mode 100644 index 00000000000..f14abb25c32 --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/binding.ts @@ -0,0 +1,15 @@ +export { + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, + LiveblocksCollaboration, +} from "./plugin"; +export type { + LiveblocksTiptapNode, + ProseMirrorJsonMark, + ProseMirrorJsonNode, +} from "./schema"; +export { + createDefaultDocument, + createLiveblocksTiptapNode, + liveblocksTiptapNodeToJson, + liveblocksTiptapNodeToJsonNodes, +} from "./schema"; diff --git a/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/cursors.ts b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/cursors.ts new file mode 100644 index 00000000000..0f59710d203 --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/cursors.ts @@ -0,0 +1,99 @@ +import { + type CollaborationCaretOptions, + type CollaborationCaretStorage, + createLiveblocksCollaborationCaretPlugin, + getCursorUser, + LIVEBLOCKS_CARET_PLUGIN_KEY, + presencePatch, +} from "@liveblocks/prosemirror"; +import { Extension } from "@tiptap/core"; + +export { LIVEBLOCKS_CARET_PLUGIN_KEY }; + +declare module "@tiptap/core" { + interface Commands { + collaborationCaret: { + updateUser: (attributes: Record) => ReturnType; + user: (attributes: Record) => ReturnType; + }; + } +} + +export const LiveblocksCollaborationCaret = Extension.create< + CollaborationCaretOptions, + CollaborationCaretStorage +>({ + name: "collaborationCaret", + priority: 999, + + addOptions() { + return { + room: undefined, + field: "default", + user: { + name: undefined, + color: undefined, + }, + }; + }, + + addStorage() { + return { + users: [], + }; + }, + + addCommands() { + return { + updateUser: + (attributes) => + ({ editor }) => { + const nextUser = + getCursorUser({ + ...this.options.user, + name: + typeof attributes.name === "string" + ? attributes.name + : this.options.user.name, + color: + typeof attributes.color === "string" + ? attributes.color + : this.options.user.color, + }) ?? {}; + + if ( + nextUser.name === this.options.user.name && + nextUser.color === this.options.user.color + ) { + return true; + } + + this.options.user = nextUser; + + if (this.options.room !== undefined) { + const { anchor, head } = editor.state.selection; + this.options.room.updatePresence( + presencePatch({ + field: this.options.field, + anchor, + head, + user: this.options.user, + }) + ); + } + return true; + }, + user: + (attributes) => + ({ editor }) => { + return editor.commands.updateUser(attributes); + }, + }; + }, + + addProseMirrorPlugins() { + return [ + createLiveblocksCollaborationCaretPlugin(this.options, this.storage), + ]; + }, +}); diff --git a/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/plugin.ts b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/plugin.ts new file mode 100644 index 00000000000..9b99ef3d489 --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/plugin.ts @@ -0,0 +1,127 @@ +import { + createLiveblocksCollaborationPlugin, + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY, + type LiveblocksProsemirrorRoom, + type ProseMirrorJsonNode, +} from "@liveblocks/prosemirror"; +import type { Content } from "@tiptap/core"; +import { Extension } from "@tiptap/core"; + +import { createDefaultDocument } from "./schema"; + +export { LIVEBLOCKS_COLLABORATION_PLUGIN_KEY }; + +type LiveblocksCollaborationOptions = { + room?: LiveblocksProsemirrorRoom; + field: string; + initialContent?: Content; +}; + +type LiveblocksCollaborationStorage = { + isDisabled: boolean; +}; + +function isProseMirrorJsonNode(value: unknown): value is ProseMirrorJsonNode { + return ( + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" + ); +} + +declare module "@tiptap/core" { + interface Commands { + collaboration: { + undo: () => ReturnType; + redo: () => ReturnType; + }; + } +} + +export const LiveblocksCollaboration = Extension.create< + LiveblocksCollaborationOptions, + LiveblocksCollaborationStorage +>({ + name: "collaboration", + priority: 1000, + + addOptions() { + return { + room: undefined, + field: "default", + initialContent: undefined, + }; + }, + + addStorage() { + return { + isDisabled: false, + }; + }, + + addCommands() { + return { + undo: + () => + ({ dispatch, tr }) => { + tr.setMeta("preventDispatch", true); + + if (this.options.room === undefined) { + return false; + } + + if (dispatch) { + this.options.room.history.resume(); + if (!this.options.room.history.canUndo()) { + return false; + } + this.options.room.history.undo(); + return true; + } + + return this.options.room.history.canUndo(); + }, + redo: + () => + ({ dispatch, tr }) => { + tr.setMeta("preventDispatch", true); + + if (this.options.room === undefined) { + return false; + } + + if (dispatch) { + this.options.room.history.resume(); + if (!this.options.room.history.canRedo()) { + return false; + } + this.options.room.history.redo(); + return true; + } + + return this.options.room.history.canRedo(); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-z": () => this.editor.commands.undo(), + "Mod-y": () => this.editor.commands.redo(), + "Shift-Mod-z": () => this.editor.commands.redo(), + }; + }, + + addProseMirrorPlugins() { + return [ + createLiveblocksCollaborationPlugin({ + room: this.options.room, + field: this.options.field, + initialContent: isProseMirrorJsonNode(this.options.initialContent) + ? this.options.initialContent + : undefined, + fallbackDocument: createDefaultDocument, + }), + ]; + }, +}); diff --git a/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/schema.ts b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/schema.ts new file mode 100644 index 00000000000..0905658b89e --- /dev/null +++ b/packages/liveblocks-react-tiptap/src/collaboration-liveblocks/schema.ts @@ -0,0 +1,28 @@ +import { + type LiveblocksProsemirrorNode, + liveblocksProsemirrorNodeToJson, + type ProseMirrorJsonNode, +} from "@liveblocks/prosemirror"; + +export type { + LiveblocksProsemirrorNode as LiveblocksTiptapNode, + ProseMirrorJsonMark, + ProseMirrorJsonNode, +} from "@liveblocks/prosemirror"; +export { + createLiveblocksProsemirrorNode as createLiveblocksTiptapNode, + getLiveblocksNodeContent, + getLiveblocksNodeId, + getLiveblocksNodeText, + liveblocksProsemirrorNodeToJsonNodes as liveblocksTiptapNodeToJsonNodes, +} from "@liveblocks/prosemirror"; + +export function createDefaultDocument(): ProseMirrorJsonNode { + return { type: "doc", content: [{ type: "paragraph" }] }; +} + +export function liveblocksTiptapNodeToJson( + node: LiveblocksProsemirrorNode +): ProseMirrorJsonNode { + return liveblocksProsemirrorNodeToJson(node, createDefaultDocument); +} diff --git a/packages/liveblocks-react-tiptap/src/comments/CommentsExtension.ts b/packages/liveblocks-react-tiptap/src/comments/CommentsExtension.ts index d20d4313bbf..93ee01a41d1 100644 --- a/packages/liveblocks-react-tiptap/src/comments/CommentsExtension.ts +++ b/packages/liveblocks-react-tiptap/src/comments/CommentsExtension.ts @@ -13,6 +13,7 @@ import type { EditorView } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { ySyncPluginKey } from "y-prosemirror"; +import { LIVEBLOCKS_COLLABORATION_PLUGIN_KEY } from "../collaboration-liveblocks/plugin"; import type { CommentsExtensionStorage, ThreadPluginState } from "../types"; import { LIVEBLOCKS_COMMENT_MARK_TYPE, @@ -362,9 +363,15 @@ export const CommentsExtension = Extension.create< this: { storage: CommentsExtensionStorage; editor: Editor }, { transaction }: { transaction: Transaction } ) { + const yjsMeta: unknown = transaction.getMeta(ySyncPluginKey); + const liveblocksMeta: unknown = transaction.getMeta( + LIVEBLOCKS_COLLABORATION_PLUGIN_KEY + ); + const isRemoteCollaborationUpdate = yjsMeta || liveblocksMeta; + // Close any pending composer when the user moves the selection locally - // (but ignore remote Yjs-driven selection changes). - if (this.storage.pendingComment && !transaction.getMeta(ySyncPluginKey)) { + // (but ignore remote collaboration-driven selection changes). + if (this.storage.pendingComment && !isRemoteCollaborationUpdate) { this.storage.pendingComment = false; } diff --git a/packages/liveblocks-react-tiptap/src/mentions/MentionExtension.ts b/packages/liveblocks-react-tiptap/src/mentions/MentionExtension.ts index e73c018c34a..84d38c4d5e1 100644 --- a/packages/liveblocks-react-tiptap/src/mentions/MentionExtension.ts +++ b/packages/liveblocks-react-tiptap/src/mentions/MentionExtension.ts @@ -16,6 +16,7 @@ import { ReactRenderer } from "@tiptap/react"; import Suggestion from "@tiptap/suggestion"; import { ySyncPluginKey } from "y-prosemirror"; +import { LIVEBLOCKS_COLLABORATION_PLUGIN_KEY } from "../collaboration-liveblocks/plugin"; import { LIVEBLOCKS_GROUP_MENTION_TYPE, LIVEBLOCKS_MENTION_EXTENSION, @@ -65,6 +66,7 @@ const mentionPasteHandler = (): Plugin => { export type MentionExtensionOptions = { onCreateMention: (mention: TiptapMentionData) => void; onDeleteMention: (notificationId: string) => void; + mentionNodes: boolean; }; /** * @@ -88,7 +90,11 @@ const notifier = ({ } // don't run if from collab if ( - transactions.some((transaction) => transaction.getMeta(ySyncPluginKey)) + transactions.some( + (transaction) => + transaction.getMeta(ySyncPluginKey) || + transaction.getMeta(LIVEBLOCKS_COLLABORATION_PLUGIN_KEY) + ) ) { return; } @@ -130,11 +136,12 @@ export const MentionExtension = Extension.create({ return { onCreateMention: () => {}, onDeleteMention: () => {}, + mentionNodes: true, }; }, addExtensions() { - return [MentionNode, GroupMentionNode]; + return this.options.mentionNodes ? [MentionNode, GroupMentionNode] : []; }, addProseMirrorPlugins() { diff --git a/packages/liveblocks-react-tiptap/src/styles/index.css b/packages/liveblocks-react-tiptap/src/styles/index.css index 9d9bea493cf..cb47151a3ed 100644 --- a/packages/liveblocks-react-tiptap/src/styles/index.css +++ b/packages/liveblocks-react-tiptap/src/styles/index.css @@ -622,14 +622,22 @@ .collaboration-carets__caret, .collaboration-cursor__caret { position: relative; - margin-inline-start: -1px; - margin-inline-end: -1px; - border-inline-start: 1px solid #0d0d0d; - border-inline-end: 1px solid #0d0d0d; word-break: normal; pointer-events: none; } +.collaboration-carets__caret::after, +.collaboration-cursor__caret::after { + content: ""; + position: absolute; + inset-inline-start: -1px; + inset-block-start: -0.15em; + block-size: 1.3em; + border-inline-start-width: 2px; + border-inline-start-style: solid; + border-inline-start-color: inherit; +} + /* Render the username above the caret */ .collaboration-carets__label, .collaboration-cursor__label { diff --git a/packages/liveblocks-react-tiptap/src/types.ts b/packages/liveblocks-react-tiptap/src/types.ts index 79a5d6261ac..97b46db81e1 100644 --- a/packages/liveblocks-react-tiptap/src/types.ts +++ b/packages/liveblocks-react-tiptap/src/types.ts @@ -86,9 +86,17 @@ export interface AiConfiguration { } export type LiveblocksExtensionOptions = { + collaborationMode?: "yjs" | "liveblocks"; field?: string; comments?: boolean; // | CommentsConfiguration mentions?: boolean; // | MentionsConfiguration + /** + * @internal + * Allows editor wrappers that add Liveblocks mention nodes through their own + * schema layer to reuse the mention plugins without registering duplicate + * TipTap node extensions. + */ + mentionNodes?: boolean; ai?: boolean | AiConfiguration; offlineSupport_experimental?: boolean; threads_experimental?: ThreadData[]; @@ -104,12 +112,20 @@ export type LiveblocksExtensionOptions = { textEditorType?: TextEditorType; }; -export type LiveblocksExtensionStorage = { - unsubs: (() => void)[]; - doc: Doc; - provider: LiveblocksYjsProvider; - permanentUserData?: PermanentUserData; -}; +export type LiveblocksExtensionStorage = + | { + mode: "yjs"; + field: string; + unsubs: (() => void)[]; + doc: Doc; + provider: LiveblocksYjsProvider; + permanentUserData?: PermanentUserData; + } + | { + mode: "liveblocks"; + field: string; + unsubs: (() => void)[]; + }; export type CommentsExtensionStorage = { pendingComment: boolean; diff --git a/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx b/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx index f8a0b1835ff..886969dae25 100644 --- a/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx +++ b/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx @@ -1,5 +1,12 @@ import type { HistoryVersion } from "@liveblocks/core"; -import { useHistoryVersionYjsData } from "@liveblocks/react"; +import { + getLiveblocksProsemirrorDocument, + liveblocksProsemirrorNodeToJson, +} from "@liveblocks/prosemirror"; +import { + useHistoryVersionStorageData, + useHistoryVersionYjsData, +} from "@liveblocks/react"; import { useOverrides } from "@liveblocks/react-ui"; import { Button, @@ -11,8 +18,14 @@ import { } from "@liveblocks/react-ui/_private"; import type { Content, Editor } from "@tiptap/react"; import { EditorContent, useEditor } from "@tiptap/react"; -import type { ComponentPropsWithoutRef } from "react"; -import { forwardRef, useCallback, useEffect } from "react"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; import { applyUpdate, Doc } from "yjs"; @@ -24,47 +37,34 @@ export interface HistoryVersionPreviewProps extends ComponentPropsWithoutRef<"di onVersionRestore?: (version: HistoryVersion) => void; } -/** - * Displays a specific version of the current TipTap document. - * - * @example - * - */ -export const HistoryVersionPreview = forwardRef< +type VersionPreviewLayoutProps = HistoryVersionPreviewProps & { + children: ReactNode; + error: Error | undefined; + isLoading: boolean; + onRestore: () => void; + restoreDisabled: boolean; +}; + +const VersionPreviewLayout = forwardRef< HTMLDivElement, - HistoryVersionPreviewProps + VersionPreviewLayoutProps >( ( - { version, editor: parentEditor, onVersionRestore, className, ...props }, + { + version, + editor: _editor, + onVersionRestore: _onVersionRestore, + children, + error, + isLoading, + onRestore, + restoreDisabled, + className, + ...props + }, forwardedRef ) => { const $ = useOverrides(); - const { isLoading, data, error } = useHistoryVersionYjsData(version.id); - - const previewEditor = useEditor({ - // ignore extensions, only get marks/nodes - editable: false, - immediatelyRender: false, - extensions: parentEditor.extensionManager.extensions.filter( - (e) => e.type !== "extension" - ), - }); - useEffect(() => { - if (data && previewEditor) { - const doc = new Doc(); - applyUpdate(doc, data); - const root = doc.getXmlFragment("default"); // TODO: lookup field - const node = yXmlFragmentToProseMirrorRootNode( - root, - parentEditor.schema - ); - previewEditor.commands.setContent(node.toJSON() as Content); - } - }, [data, previewEditor, parentEditor]); - const restore = useCallback(() => { - parentEditor.commands.setContent(previewEditor?.getJSON() ?? ""); - onVersionRestore?.(version); - }, [onVersionRestore, parentEditor, previewEditor, version]); return (
    ) : (
    - + {children}
    )}
    @@ -105,8 +105,8 @@ export const HistoryVersionPreview = forwardRef<