diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d73f85123..16165a18f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## vNEXT (not yet released) +## v3.18.5 + +### `@liveblocks/react-tiptap` + +- Support overlapping comment marks, they now all appear in the + `AnchoredThreads` and `FloatingThreads` components. + ## v3.18.4 ### `@liveblocks/emails` @@ -3546,13 +3553,11 @@ In **@liveblocks/react**: https://liveblocks.io/docs/guides/troubleshooting#stale-props-zombie-child - In **@liveblocks/zustand**: - - Fix a confusing error message ## v0.18.2 - In **@liveblocks/react**: - - Make sure that `useOther` will not rerender if tracked users already left the room, so that child components won't get rerendered before the parent got the chance to unmount them. @@ -3561,7 +3566,6 @@ In **@liveblocks/react**: ## v0.18.1 - In **@liveblocks/react**: - - Fix a bug that could cause an error when patching presence during local development. Not an issue in production builds. (#505) @@ -3573,7 +3577,6 @@ For information, please read our ### New React hooks ✨ - In **@liveblocks/react**: - - [`useStorage`](https://liveblocks.io/docs/api-reference/liveblocks-react#useStorage) - [`useMutation`](https://liveblocks.io/docs/api-reference/liveblocks-react#useMutation) - [`useSelf`](https://liveblocks.io/docs/api-reference/liveblocks-react#useSelf) @@ -3584,7 +3587,6 @@ For information, please read our (singular) - In **@liveblocks/client**: - - New [`.toImmutable()`](https://liveblocks.io/docs/api-reference/liveblocks-client#LiveObject.toImmutable) method on `LiveObject`, `LiveList`, and `LiveMap` lets you work with an @@ -3630,19 +3632,16 @@ In **@liveblocks/react**: ### New history APIs ↩️ ↪️ - In **@liveblocks/client**: - - Add `canUndo()` and `canRedo()` utilities to `room.history` - Add `"history"` event type to `room.subscribe()` to subscribe to the current user's history changes - In **@liveblocks/react**: - - Add `useCanUndo()` and `useCanRedo()` hooks ## v0.17.7 - In **@liveblocks/zustand**: - - Simplify zustand middleware integration with Typescript. `TPresence`, `TStorage`, `TUserMeta`, and `TRoomEvent` are now optional. @@ -3724,13 +3723,11 @@ useStore(state => state.liveblocks.others[0].presence?.isTyping) ## v0.17.6 - In **@liveblocks/react**: - - Expose `RoomContext` in the return value of `createRoomContext()` ## v0.17.5 - In **@liveblocks/react**: - - Fix bug where changing the `key` argument of `useMap()`, `useList()`, `useObject()` did not resubscribe to updates correctly - Ignore changes to the `RoomProvider`'s initial presence/storage props on @@ -3782,12 +3779,10 @@ It's surprisingly simple! ### New APIs ✨ - In **@liveblocks/react**: - - [`createRoomContext()`](https://liveblocks.io/docs/api-reference/liveblocks-react#createRoomContext) is now the preferred way to initialize hooks. - In the API: - - New endpoint to [Get Users in a Room](https://liveblocks.io/docs/api-reference/rest-api-endpoints#GetRoomUsers) - New endpoint to @@ -3801,13 +3796,11 @@ It's surprisingly simple! ### Breaking changes - In **@liveblocks/client**: - - Removed old `Room.unsubscribe()` API ### New deprecations - In **@liveblocks/client**: - - The `defaultPresence` option to `client.enter()` will get renamed to `initialPresence` - The `defaultStorageRoot` option to `client.enter()` will get renamed to @@ -3816,7 +3809,6 @@ It's surprisingly simple! or `new LiveMap([])` - In **@liveblocks/react**: - - Importing the React hooks directly is deprecated, instead use the new `createRoomContext()` helper. For help, read the [Recommended Upgrade Steps section](https://liveblocks.io/docs/platform/upgrading/0.17#recommended-upgrade-steps) @@ -3887,7 +3879,6 @@ Fix bug in example code suggested in deprecation warning. ### Bug fixes - In **@liveblocks/client**: - - If you're using `@liveblocks/client` in a ES2015 context, you no longer have to polyfill `Object.fromEntries()`. @@ -3905,15 +3896,12 @@ Fix bug in example code suggested in deprecation warning. ### Bug fixes - In **@liveblocks/client**: - - Fix bug where internal presence state could not get restored correctly after undo/redo in certain circumstances. - In **@liveblocks/zustand** and **@liveblocks/redux**: - - Fixes an issue when initializing an array with items would result in having duplicated items in other clients. Example: - - Client A updates state : `{ list: [0] }` - Client B states is updated to : `{ list: [0, 0] }` @@ -3922,7 +3910,6 @@ Fix bug in example code suggested in deprecation warning. ### Bug fixes - In **@liveblocks/client**: - - Fix small bug related to new `JsonObject` type, which would reject some values that were legal JSON objects. @@ -3931,7 +3918,6 @@ Fix bug in example code suggested in deprecation warning. ### Bug fixes - In **@liveblocks/react**: - - Fix issue with React 18 and StrictMode. ## v0.16.0 diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index 6b78aa2cf23..a26f67edc8d 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.18.4", + "version": "3.18.5", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 780acf4a933..8045a3eb89c 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.18.4", + "version": "3.18.5", "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.", diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 9fd3b694ef4..bda7388b999 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.18.4", + "version": "3.18.5", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 7818af0c6d3..75815d240e6 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.18.4", + "version": "3.18.5", "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-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 6b9fd889c42..cd65b4f0c34 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.18.4", + "version": "3.18.5", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 6a5b03d6565..1da8bba946a 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.18.4", + "version": "3.18.5", "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 7fa8f5f550b..f8c4e8dd508 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.18.4", + "version": "3.18.5", "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.", diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index efeae352638..309fa473a2f 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.18.4", + "version": "3.18.5", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json index db18d1de390..8f9253e8890 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.18.4", + "version": "3.18.5", "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index 86e4f1a329f..a0d9f2e1c1c 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.18.4", + "version": "3.18.5", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index a620cf1c303..a371a1c99f4 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.18.4", + "version": "3.18.5", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts b/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts index 4c669afd17f..77c3e0dc6aa 100644 --- a/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts +++ b/packages/liveblocks-react-tiptap/src/LiveblocksExtension.ts @@ -28,7 +28,6 @@ import { type CollaborationCaretOptions, } from "./collaboration-caret/collaboration-caret"; import { - areSetsEqual, CommentsExtension, FILTERED_THREADS_PLUGIN_KEY, } from "./comments/CommentsExtension"; @@ -40,6 +39,7 @@ import type { ResolveContextualPromptResponse, } from "./types"; import { LIVEBLOCKS_COMMENT_MARK_TYPE } from "./types"; +import { areSetsEqual } from "./utils"; type WithRequired = T & { [P in K]-?: T[P] }; diff --git a/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts b/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts index d66602762c6..8e3b7a5d91a 100644 --- a/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts +++ b/packages/liveblocks-react-tiptap/src/ai/AiExtension.ts @@ -139,7 +139,8 @@ export const AiExtension = Extension.create< pud: undefined, // The actual default resolver is set in LiveblocksExtension via AiExtension.configure() - resolveContextualPrompt: () => Promise.reject(new Error("resolveContextualPrompt not configured")), + resolveContextualPrompt: () => + Promise.reject(new Error("resolveContextualPrompt not configured")), name: DEFAULT_AI_NAME, }; }, diff --git a/packages/liveblocks-react-tiptap/src/comments/AnchoredThreads.tsx b/packages/liveblocks-react-tiptap/src/comments/AnchoredThreads.tsx index 9820be6e47d..32027c0a31f 100644 --- a/packages/liveblocks-react-tiptap/src/comments/AnchoredThreads.tsx +++ b/packages/liveblocks-react-tiptap/src/comments/AnchoredThreads.tsx @@ -1,4 +1,10 @@ -import type { BaseMetadata, DCM, DTM, ThreadData } from "@liveblocks/core"; +import { + type BaseMetadata, + type DCM, + type DTM, + shallow, + type ThreadData, +} from "@liveblocks/core"; import { useLayoutEffect } from "@liveblocks/react/_private"; import { Thread as DefaultThread, @@ -71,14 +77,16 @@ export function AnchoredThreads({ equalityFn: (prev, next) => { if (!prev || !next) return false; return ( - prev.pluginState?.selectedThreadId === - next.pluginState?.selectedThreadId && - prev.pluginState?.threadPositions === next.pluginState?.threadPositions - ); // new map is made each time threadPos updates so shallow equality is fine + prev.pluginState?.threadPositions === + next.pluginState?.threadPositions && + shallow( + prev.pluginState?.activeThreadIds, + next.pluginState?.activeThreadIds + ) + ); }, }) ?? { pluginState: undefined }; - // TODO: lexical supoprts multiple threads being active, should probably do that here as well const handlePositionThreads = useCallback(() => { const container = containerRef.current; if ( @@ -90,13 +98,20 @@ export function AnchoredThreads({ return; } - const activeIndex = orderedThreads.findIndex( - ({ thread }) => thread.id === pluginState?.selectedThreadId - ); + const activeIds = pluginState?.activeThreadIds ?? []; + + const firstActiveIndex = + activeIds.length === 0 + ? -1 + : orderedThreads.findIndex(({ thread }) => + activeIds.includes(thread.id) + ); const ascending = - activeIndex !== -1 ? orderedThreads.slice(activeIndex) : orderedThreads; + firstActiveIndex !== -1 + ? orderedThreads.slice(firstActiveIndex) + : orderedThreads; const descending = - activeIndex !== -1 ? orderedThreads.slice(0, activeIndex) : []; + firstActiveIndex !== -1 ? orderedThreads.slice(0, firstActiveIndex) : []; const newPositions = new Map(); @@ -142,7 +157,7 @@ export function AnchoredThreads({ } setPositions(newPositions); - }, [editor, orderedThreads, pluginState?.selectedThreadId, elements]); + }, [editor, orderedThreads, pluginState?.activeThreadIds, elements]); useEffect(() => { if (!pluginState) return; @@ -232,7 +247,8 @@ export function AnchoredThreads({ top = positions.get(thread.id)!; } - const isActive = thread.id === pluginState?.selectedThreadId; + const isActive = + pluginState?.activeThreadIds.includes(thread.id) ?? false; return ( ; }>(); -/** - * Known issues: Overlapping marks are merged when reloading the doc. May be related: - * https://github.com/ueberdosis/tiptap/issues/4339 - * https://github.com/yjs/y-prosemirror/issues/47 - */ +function getFilteredThreads(state: EditorState): Set | undefined { + return FILTERED_THREADS_PLUGIN_KEY.getState(state)?.filteredThreads; +} + +function getVisibleThreadIdsFromMarks( + marks: readonly ProseMirrorMark[], + markType: MarkType, + filteredThreads: Set | undefined +): string[] { + const ids = new Set(); + for (const mark of marks) { + if (mark.type !== markType || mark.attrs.orphan) continue; + const threadId = mark.attrs.threadId as string | undefined; + if (!threadId) continue; + if (filteredThreads && !filteredThreads.has(threadId)) continue; + ids.add(threadId); + } + return [...ids]; +} + +function getVisibleThreadIdsAtPos( + state: EditorState, + $pos: ResolvedPos, + markType: MarkType +): string[] { + return getVisibleThreadIdsFromMarks( + $pos.marks(), + markType, + getFilteredThreads(state) + ); +} + +function dispatchSetActiveThreadIds(view: EditorView, ids: string[]): void { + view.dispatch( + view.state.tr.setMeta(THREADS_PLUGIN_KEY, { + name: ThreadPluginActions.SET_ACTIVE_THREAD_IDS, + data: ids, + } satisfies ThreadPluginAction) + ); +} + const Comment = Mark.create({ name: LIVEBLOCKS_COMMENT_MARK_TYPE, excludes: "", @@ -97,62 +141,59 @@ const Comment = Mark.create({ * This plugin tracks the (first) position of each thread mark in the doc and creates a decoration for the selected thread */ addProseMirrorPlugins() { - const updateState = (doc: Node, selectedThreadId: string | null) => { + const updateState = ( + doc: Node, + activeThreadIds: string[], + { scroll }: { scroll: boolean } + ): ThreadPluginState => { const threadPositions = new Map(); const decorations: Decoration[] = []; - // find all thread marks and store their position + create decoration for selected thread + const activeSet = new Set(activeThreadIds); + doc.descendants((node, pos) => { - node.marks.forEach((mark) => { - if (mark.type === this.type) { - const thisThreadId = ( - mark.attrs as { threadId: string | undefined } - ).threadId; - if (!thisThreadId) { - return; - } - const from = pos; - const to = from + node.nodeSize; - - // FloatingThreads component uses "to" as the position, so always store the largest "to" found - // AnchoredThreads component uses "from" as the position, so always store the smallest "from" found - const currentPosition = threadPositions.get(thisThreadId) ?? { - from: Infinity, - to: 0, - }; - threadPositions.set(thisThreadId, { - from: Math.min(from, currentPosition.from), - to: Math.max(to, currentPosition.to), - }); + for (const mark of node.marks) { + if (mark.type !== this.type) continue; - if (selectedThreadId === thisThreadId) { - decorations.push( - Decoration.inline(from, to, { - class: "lb-root lb-tiptap-thread-mark-selected", - }) - ); - - const decoration = this.editor.view.dom.querySelector( - `.lb-tiptap-thread-mark[data-lb-thread-id="${thisThreadId}"]` - ); - - if (decoration) { - decoration.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - } - } + const threadId = (mark.attrs as { threadId?: string }).threadId; + if (!threadId) continue; + + const from = pos; + const to = from + node.nodeSize; + + // FloatingThreads component uses "to" as the position, so we always store the largest "to" found. + // AnchoredThreads component uses "from" as the position, so we always store the smallest "from" found. + const current = threadPositions.get(threadId) ?? { + from: Infinity, + to: 0, + }; + threadPositions.set(threadId, { + from: Math.min(from, current.from), + to: Math.max(to, current.to), + }); + + if (activeSet.has(threadId)) { + decorations.push( + Decoration.inline(from, to, { + class: "lb-root lb-tiptap-thread-mark-selected", + }) + ); } - }); + } }); + + // Only scroll when the active selection explicitly changes. + if (scroll && activeThreadIds.length > 0) { + const [scrollTargetId] = activeThreadIds; + const element = this.editor.view.dom.querySelector( + `.lb-tiptap-thread-mark[data-lb-thread-id="${scrollTargetId}"]` + ); + element?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + return { decorations: DecorationSet.create(doc, decorations), - selectedThreadId, + activeThreadIds, threadPositions, - selectedThreadPos: - selectedThreadId !== null - ? (threadPositions.get(selectedThreadId)?.to ?? null) - : null, }; }; @@ -208,30 +249,34 @@ const Comment = Mark.create({ new Plugin({ key: THREADS_PLUGIN_KEY, state: { - init() { + init(): ThreadPluginState { return { - threadPositions: new Map(), - selectedThreadId: null, - selectedThreadPos: null, + threadPositions: new Map(), + activeThreadIds: [], decorations: DecorationSet.empty, - } as ThreadPluginState; + }; }, apply(tr, state) { - const action = tr.getMeta(THREADS_PLUGIN_KEY) as ThreadPluginAction; + const action = tr.getMeta(THREADS_PLUGIN_KEY) as + | ThreadPluginAction + | undefined; + if (!tr.docChanged && !action) { return state; } if (!action) { - // Doc changed, but no action, just update rects - return updateState(tr.doc, state.selectedThreadId); + return updateState(tr.doc, state.activeThreadIds, { + scroll: false, + }); } - // handle actions, possibly support more actions - if ( - action.name === ThreadPluginActions.SET_SELECTED_THREAD_ID && - state.selectedThreadId !== action.data - ) { - return updateState(tr.doc, action.data); + + if (action.name === ThreadPluginActions.SET_ACTIVE_THREAD_IDS) { + const idsChanged = !shallow(action.data, state.activeThreadIds); + if (!tr.docChanged && !idsChanged) { + return state; + } + return updateState(tr.doc, action.data, { scroll: idsChanged }); } return state; @@ -245,43 +290,11 @@ const Comment = Mark.create({ ); }, handleClick: (view, pos, event) => { - if (event.button !== 0) { - return; - } - - const selectThread = (threadId: string | null) => { - view.dispatch( - view.state.tr.setMeta(THREADS_PLUGIN_KEY, { - name: ThreadPluginActions.SET_SELECTED_THREAD_ID, - data: threadId, - }) - ); - }; - - const node = view.state.doc.nodeAt(pos); - if (!node) { - selectThread(null); - return; - } - const commentMark = node.marks.find( - (mark) => mark.type === this.type && !mark.attrs.orphan - ); - // nothing to select - if (!commentMark) { - selectThread(null); - return; - } - const threadId = commentMark?.attrs.threadId as string | undefined; - - const filtered = FILTERED_THREADS_PLUGIN_KEY.getState( - view.state - )?.filteredThreads; - if (threadId && filtered && !filtered.has(threadId)) { - selectThread(null); - return; - } + if (event.button !== 0) return; - selectThread(threadId ?? null); + const $pos = view.state.doc.resolve(pos); + const ids = getVisibleThreadIdsAtPos(view.state, $pos, this.type); + dispatchSetActiveThreadIds(view, ids); }, }, }), @@ -311,13 +324,8 @@ export const CommentsExtension = Extension.create< if (this.editor.state.selection.empty) { return false; } - // unselect any open threads - this.editor.view.dispatch( - this.editor.state.tr.setMeta(THREADS_PLUGIN_KEY, { - name: ThreadPluginActions.SET_SELECTED_THREAD_ID, - data: null, - }) - ); + // Unselect any open threads. + dispatchSetActiveThreadIds(this.editor.view, []); this.storage.pendingComment = true; return true; }, @@ -326,25 +334,13 @@ export const CommentsExtension = Extension.create< return true; }, selectThread: (id: string | null) => () => { - const filtered = FILTERED_THREADS_PLUGIN_KEY.getState( - this.editor.state - )?.filteredThreads; - if (id && filtered && !filtered.has(id)) { - this.editor.view.dispatch( - this.editor.state.tr.setMeta(THREADS_PLUGIN_KEY, { - name: ThreadPluginActions.SET_SELECTED_THREAD_ID, - data: null, - }) - ); - return true; - } + // If the target thread is filtered out, clear the active selection + // instead of selecting an invisible thread. + const filtered = getFilteredThreads(this.editor.state); + const nextIds = + id === null || (filtered && !filtered.has(id)) ? [] : [id]; - this.editor.view.dispatch( - this.editor.state.tr.setMeta(THREADS_PLUGIN_KEY, { - name: ThreadPluginActions.SET_SELECTED_THREAD_ID, - data: id, - }) - ); + dispatchSetActiveThreadIds(this.editor.view, nextIds); return true; }, addComment: @@ -363,15 +359,30 @@ export const CommentsExtension = Extension.create< }; }, onSelectionUpdate( - this: { storage: CommentsExtensionStorage }, // NOTE: there are more types here I didn't override, this gets removed after submitting PR to tiptap - { transaction }: { transaction: Transaction } // TODO: remove this after submitting PR to tiptap + this: { storage: CommentsExtensionStorage; editor: Editor }, + { transaction }: { transaction: Transaction } ) { - // ignore changes made by yjs - if (!this.storage.pendingComment || transaction.getMeta(ySyncPluginKey)) { - return; + // 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)) { + this.storage.pendingComment = false; } - // if selection changes, hide the composer. We could keep the composer open and move it to the new selection? - this.storage.pendingComment = false; + + if (this.storage.pendingComment) return; + + const { state } = this.editor; + const markType = state.schema.marks[LIVEBLOCKS_COMMENT_MARK_TYPE]; + if (!markType) return; + + const ids = getVisibleThreadIdsAtPos( + state, + state.selection.$from, + markType + ); + const current = THREADS_PLUGIN_KEY.getState(state)?.activeThreadIds ?? []; + if (shallow(ids, current)) return; + + dispatchSetActiveThreadIds(this.editor.view, ids); }, addProseMirrorPlugins() { return [ @@ -447,16 +458,15 @@ export const CommentsExtension = Extension.create< ) { syncDom(); - const selected = THREADS_PLUGIN_KEY.getState( - view.state - )?.selectedThreadId; - if (selected && curr && !curr.has(selected)) { - view.dispatch( - view.state.tr.setMeta(THREADS_PLUGIN_KEY, { - name: ThreadPluginActions.SET_SELECTED_THREAD_ID, - data: null, - }) - ); + const active = + THREADS_PLUGIN_KEY.getState(view.state)?.activeThreadIds ?? + []; + + if (active.length && curr) { + const next = active.filter((id) => curr.has(id)); + if (next.length !== active.length) { + dispatchSetActiveThreadIds(view, next); + } } } }, @@ -466,11 +476,3 @@ export const CommentsExtension = Extension.create< ]; }, }); - -export function areSetsEqual(a?: Set, b?: Set): boolean { - if (a === b) return true; - if (!a || !b) return false; - if (a.size !== b.size) return false; - for (const v of a) if (!b.has(v)) return false; - return true; -} diff --git a/packages/liveblocks-react-tiptap/src/comments/FloatingThreads.tsx b/packages/liveblocks-react-tiptap/src/comments/FloatingThreads.tsx index 4e0c07b55b6..61bf36c7f4c 100644 --- a/packages/liveblocks-react-tiptap/src/comments/FloatingThreads.tsx +++ b/packages/liveblocks-react-tiptap/src/comments/FloatingThreads.tsx @@ -8,7 +8,13 @@ import { size, useFloating, } from "@floating-ui/react-dom"; -import type { BaseMetadata, DCM, DTM, ThreadData } from "@liveblocks/core"; +import { + type BaseMetadata, + type DCM, + type DTM, + shallow, + type ThreadData, +} from "@liveblocks/core"; import { useLayoutEffect } from "@liveblocks/react/_private"; import { Thread as DefaultThread, @@ -27,6 +33,7 @@ import { } from "react"; import { THREADS_PLUGIN_KEY } from "../types"; +import { compareDocumentPosition } from "../utils"; type FloatingThreadsComponents = { Thread: ComponentType; @@ -60,63 +67,109 @@ export function FloatingThreads({ }: FloatingThreadsProps) { const Thread = useStableComponent(components?.Thread, DefaultThread); - const { pluginState } = useEditorState({ - editor, - selector: (ctx) => { - if (!ctx?.editor?.state) return { pluginState: undefined }; - const state = THREADS_PLUGIN_KEY.getState(ctx.editor.state); - return { - pluginState: state, - }; - }, - equalityFn: (prev, next) => { - if (!prev || !next) return false; - return ( - prev.pluginState?.selectedThreadPos === - next.pluginState?.selectedThreadPos && - prev.pluginState?.selectedThreadId === - next.pluginState?.selectedThreadId - ); - }, - }) ?? { pluginState: undefined }; + const activeThreadIds = + useEditorState({ + editor, + selector: (ctx) => { + if (!ctx?.editor?.state) { + return undefined; + } - const [activeThread, setActiveThread] = useState(null); + const state = THREADS_PLUGIN_KEY.getState(ctx.editor.state); - useEffect(() => { - if (!editor || !pluginState) { - setActiveThread(null); + return state?.activeThreadIds; + }, + equalityFn: (prev, next) => { + if (!prev || !next) return false; + return shallow(prev, next); + }, + }) ?? undefined; + + const [range, setRange] = useState<{ + range: Range; + threads: ThreadData[]; + } | null>(null); + + const handleUpdateRange = useCallback(() => { + if ( + !editor || + !editor.view || + editor.view.isDestroyed || + !activeThreadIds + ) { + setRange(null); return; } - const { selectedThreadId, selectedThreadPos } = pluginState; - if (selectedThreadId === null || selectedThreadPos === null) { - setActiveThread(null); + + if (activeThreadIds.length === 0) { + setRange(null); return; } - const active = (threads ?? []).find( - (thread) => selectedThreadId === thread.id + + const activeThreads = (threads ?? []).filter( + (thread) => activeThreadIds.includes(thread.id) && !thread.resolved ); - setActiveThread(active ?? null); - }, [editor, pluginState, threads]); + if (activeThreads.length === 0) { + setRange(null); + return; + } + + // A thread mark can be split across multiple DOM elements (e.g. when + // overlapping with another mark), so we collect every matching element and + // build a DOM range spanning from the first to the last one. + const elements = new Set(); + for (const id of activeThreadIds) { + const els = editor.view.dom.querySelectorAll( + `span.lb-tiptap-thread-mark[data-lb-thread-id="${id}"]` + ); + els.forEach((el) => elements.add(el)); + } + + const sorted = Array.from(elements).sort(compareDocumentPosition); + if (sorted.length === 0) { + setRange(null); + return; + } + + const domRange = document.createRange(); + domRange.setStartBefore(sorted[0]); + domRange.setEndAfter(sorted[sorted.length - 1]); + setRange({ range: domRange, threads: activeThreads }); + }, [editor, activeThreadIds, threads]); + + // Remote cursor updates and other edits can shift the underlying DOM + // elements, so we recompute the range on every change. + useEffect(() => { + if (!editor) return; + editor.on("transaction", handleUpdateRange); + return () => { + editor.off("transaction", handleUpdateRange); + }; + }, [editor, handleUpdateRange]); + + useLayoutEffect(handleUpdateRange, [handleUpdateRange]); const handleEscapeKeydown = useCallback((): boolean => { - if (!editor || activeThread === null) return false; + if (!editor || range === null) return false; editor.commands.selectThread(null); return true; - }, [activeThread, editor]); + }, [editor, range]); - if (!activeThread || !editor || activeThread.resolved) return null; + if (range === null) { + return null; + } return ( - - {activeThread && ( + + {range.threads.map((thread) => ( - )} + ))} ); } @@ -125,16 +178,14 @@ interface FloatingThreadPortalProps extends Omit< HTMLAttributes, "children" > { - thread: ThreadData; - editor: Editor; + range: Range; children: ReactNode; } export const FLOATING_THREAD_COLLISION_PADDING = 10; function FloatingThreadPortal({ - editor, - thread, + range, children, className, style, @@ -177,24 +228,11 @@ function FloatingThreadPortal({ }, }); - const updateRef = useCallback(() => { - const el = editor.view.dom.querySelector( - `[data-lb-thread-id="${thread.id}"]` - ); - if (el) { - setReference(el); - } - }, [setReference, editor, thread.id]); - - // Remote cursor updates and other edits can cause the ref to break - useEffect(() => { - editor.on("transaction", updateRef); - return () => { - editor.off("transaction", updateRef); - }; - }, [editor, updateRef]); - - useLayoutEffect(updateRef, [updateRef]); + useLayoutEffect(() => { + setReference({ + getBoundingClientRect: () => range.getBoundingClientRect(), + }); + }, [setReference, range]); return ( diff --git a/packages/liveblocks-react-tiptap/src/types.ts b/packages/liveblocks-react-tiptap/src/types.ts index dfd6b4880e5..79a5d6261ac 100644 --- a/packages/liveblocks-react-tiptap/src/types.ts +++ b/packages/liveblocks-react-tiptap/src/types.ts @@ -116,7 +116,7 @@ export type CommentsExtensionStorage = { }; export const enum ThreadPluginActions { - SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID", + SET_ACTIVE_THREAD_IDS = "SET_ACTIVE_THREAD_IDS", } export type AiExtensionOptions = Required< @@ -270,8 +270,7 @@ declare module "@tiptap/core" { } export type ThreadPluginState = { threadPositions: Map; - selectedThreadId: string | null; - selectedThreadPos: number | null; + activeThreadIds: string[]; decorations: DecorationSet; }; diff --git a/packages/liveblocks-react-tiptap/src/utils.ts b/packages/liveblocks-react-tiptap/src/utils.ts index 3b8e8949061..e58eb24b3e0 100644 --- a/packages/liveblocks-react-tiptap/src/utils.ts +++ b/packages/liveblocks-react-tiptap/src/utils.ts @@ -66,6 +66,25 @@ export const getRectFromCoords = (coords: { }; }; +export const compareDocumentPosition = (a: Node, b: Node) => { + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; +}; + +export const areSetsEqual = (a?: Set, b?: Set): boolean => { + if (a === b) return true; + if (!a || !b) return false; + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +}; + export const getMentionsFromNode = ( node: ProseMirrorNode, range: Range diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 6d4e8f20b11..45d970dac28 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.18.4", + "version": "3.18.5", "description": "A set of React pre-built components for the Liveblocks products. 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-react/package.json b/packages/liveblocks-react/package.json index 73a0de53bd1..b375c02c6ae 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.18.4", + "version": "3.18.5", "description": "A set of React hooks and providers to use Liveblocks declaratively. 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-redux/package.json b/packages/liveblocks-redux/package.json index 5d845698e27..b716c7c1c46 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.18.4", + "version": "3.18.5", "description": "A store enhancer to integrate Liveblocks into Redux stores. 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-server/package.json b/packages/liveblocks-server/package.json index 17e204feabf..1e1d0fbf234 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -51,7 +51,7 @@ "@liveblocks/jest-config": "workspace:*", "@types/node": "^24.12.2", "eslint": "^9.39.4", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-license-header": "^0.9.0", "eslint-plugin-simple-import-sort": "^13.0.0", "fast-check": "^3.23.2", diff --git a/packages/liveblocks-server/src/formats/PlainLson.ts b/packages/liveblocks-server/src/formats/PlainLson.ts index 26228d944b9..26c34169444 100644 --- a/packages/liveblocks-server/src/formats/PlainLson.ts +++ b/packages/liveblocks-server/src/formats/PlainLson.ts @@ -232,7 +232,11 @@ function buildNode(snapshot: IReadableSnapshot, id: string): PlainLson { } else if (node.type === CrdtType.MAP) { return buildMap(snapshot, id); } else { - return node.data; + // TEMPORARY: `?? null` is only here to project legacy KV rooms that + // contain data-less registers (under a LiveMap, representing `null` + // entries), so the KV projection matches how the SQLite migrator + // normalizes them. Remove once all rooms have been migrated to SQLite. + return node.data ?? null; } } @@ -301,7 +305,9 @@ function* emit(snapshot: IReadableSnapshot, id: string): StringGen { } else if (node.type === CrdtType.MAP) { yield* emitMap(snapshot, id); } else if (node.type === CrdtType.REGISTER) { - yield JSON.stringify(node.data); + // TEMPORARY: see buildNode — remove `?? null` once all rooms are + // migrated to SQLite. + yield JSON.stringify(node.data ?? null); } } diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 4232440004a..24f87e42f4d 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.18.4", + "version": "3.18.5", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index 6369d77afe7..7dae6da4084 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.18.4", + "version": "3.18.5", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. 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/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index 59ed2d5e65f..77ef019f46f 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -36,7 +36,7 @@ "@liveblocks/eslint-config": "workspace:*", "bun-types": "^1.3.9", "eslint": "^9.39.4", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-license-header": "^0.9.0", "eslint-plugin-simple-import-sort": "^13.0.0", "fast-check": "^3.23.2",