diff --git a/packages/super-editor/src/ui/comments.test.ts b/packages/super-editor/src/ui/comments.test.ts index 516dfe9b3b..d34b55e897 100644 --- a/packages/super-editor/src/ui/comments.test.ts +++ b/packages/super-editor/src/ui/comments.test.ts @@ -379,6 +379,32 @@ describe('ui.comments — actions route through editor.doc.*', () => { ui.destroy(); }); + it('createFromCapture accepts a Document API SelectionInfo (the pending-event shape) without a cast', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // Shape of `pendingSelection` on a pending `comments-update` event: + // a SelectionInfo (target + text), NOT a full SelectionCapture (no + // selectionTarget / quotedText). The widened CommentAnchorCapture + // input accepts it directly — no cast here, so this also pins the + // type widening at compile time. + const pendingSelection = { + empty: false, + target: { kind: 'text' as const, segments: [{ blockId: 'p1', range: { start: 0, end: 4 } }] }, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + text: 'word', + }; + + const receipt = ui.comments.createFromCapture(pendingSelection, { text: 'from pending' }); + + expect(receipt.success).toBe(true); + expect(mocks.create).toHaveBeenCalledWith({ target: pendingSelection.target, text: 'from pending' }); + + ui.destroy(); + }); + it('reply forwards to comments.create with parentCommentId set and no target', () => { const { superdoc, mocks } = makeStubs(); const ui = createSuperDocUI({ superdoc }); diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 68e778013e..5164137fc5 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -115,6 +115,7 @@ export type { UIToolbarCommandState, // Comments + CommentAnchorCapture, CommentsHandle, CommentsSlice, diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 3517136ce2..04d82af260 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -1174,6 +1174,19 @@ export interface SelectionAnchorRectOptions { */ export type SelectionCapture = SelectionSlice; +/** + * The minimal anchor `ui.comments.createFromCapture` needs: anything + * carrying a positional `target`. Both a full {@link SelectionCapture} + * (from `ui.selection.capture()`) and the Document API + * {@link import('@superdoc/document-api').SelectionInfo} satisfy it — + * the latter is what a pending `comments-update` event exposes as + * `pendingSelection`, so consumers can forward it straight to + * `createFromCapture` without first reconstructing a `SelectionCapture`. + */ +export type CommentAnchorCapture = { + target: import('@superdoc/document-api').TextTarget | null; +}; + /** * Aggregate toolbar handle exposed on `ui.toolbar`. Compatible with * `HeadlessToolbarController` from `superdoc/headless-toolbar` so the @@ -1670,8 +1683,13 @@ export interface CommentsHandle { * pass it here. Routes through `editor.doc.comments.create` with * the captured `target`. Returns a `NO_OP` receipt when the capture * lacks a positional target. + * + * Accepts any {@link CommentAnchorCapture} (only `target` is read), + * so the `pendingSelection` from a pending `comments-update` event — + * a Document API `SelectionInfo` — can be passed directly without + * reconstructing a full `SelectionCapture`. */ - createFromCapture(capture: SelectionCapture, input: { text: string }): import('@superdoc/document-api').Receipt; + createFromCapture(capture: CommentAnchorCapture, input: { text: string }): import('@superdoc/document-api').Receipt; /** * Post a reply to an existing thread. Routes through * `editor.doc.comments.create({ parentCommentId, text })`; the diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 1d085c6ff1..631e7d0f7d 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -31,6 +31,7 @@ import type { FontsResolvedPayload, ListDefinitionsPayload, ProofingProvider, + SelectionInfo, User, } from '@superdoc/super-editor'; @@ -1416,6 +1417,19 @@ export interface SuperDocCommentsUpdatePayload { comment?: Comment; /** Per-field change set when the update is a mutation. */ changes?: Array<{ key: string; commentId: string; fileId?: string | null }>; + /** + * The Document API selection snapshot captured at the moment a + * `'pending'` comment was started, before the pending mark is + * inserted (which clears the live DOM selection). Present only on the + * `'pending'` event. Forward it straight to + * `ui.comments.createFromCapture(pendingSelection, { text })` to build + * the comment from a custom composer — it removes the need to track + * the selection yourself ahead of the floating-bubble click. A + * non-text or empty selection still yields a `SelectionInfo` whose + * `target` is `null`; the field itself is `null` only when there's no + * active editor. + */ + pendingSelection?: SelectionInfo | null; } export interface EditorTransactionEvent { diff --git a/packages/superdoc/src/public/ui.ts b/packages/superdoc/src/public/ui.ts index f7ed92b0d1..cdf008094d 100644 --- a/packages/superdoc/src/public/ui.ts +++ b/packages/superdoc/src/public/ui.ts @@ -48,6 +48,7 @@ export type { CommandHandle, CommandsHandle, CommentAddress, + CommentAnchorCapture, CommentInfo, CommentsHandle, CommentsListQuery, diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 96b6aaaa09..a17b1d9992 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -857,7 +857,15 @@ export const useCommentsStore = defineStore('comments', () => { }; const showAddComment = (superdoc, targetClientY = null) => { - const event = { type: COMMENT_EVENTS.PENDING }; + // Snapshot the selection BEFORE `insertComment('pending')` below adds + // the pending mark and the floating-bubble click clears the live DOM + // selection. This is the Document API `SelectionInfo` (carries + // `target`); consumers forward it to `ui.comments.createFromCapture` + // instead of tracking the selection themselves. A non-text/empty + // selection still yields a `SelectionInfo` with `target: null`; the + // value is `null` only when there's no active editor / selection API. + const pendingSelection = superdoc.activeEditor?.doc?.selection?.current?.({ includeText: true }) ?? null; + const event = { type: COMMENT_EVENTS.PENDING, pendingSelection }; superdoc.emit('comments-update', event); const selection = { ...superdocStore.activeSelection }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 9bab624d46..1bdb1af7ef 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -280,6 +280,84 @@ describe('comments-store', () => { expect(setActiveCommentSpy).toHaveBeenCalledWith({ commentId: null }); }); + describe('showAddComment pendingSelection (IT-1113)', () => { + const makePendingSuperdoc = (selectionInfo) => { + const insertComment = vi.fn(); + const current = vi.fn(() => selectionInfo); + return { + superdoc: { + emit: vi.fn(), + config: { isInternal: false }, + activeEditor: { + doc: { selection: { current } }, + commands: { insertComment }, + }, + }, + insertComment, + current, + }; + }; + + it('emits the pre-mutation Document API selection snapshot on the pending event', () => { + const selectionInfo = { + empty: false, + target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 5 } }] }, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + text: 'hello', + }; + const { superdoc, current } = makePendingSuperdoc(selectionInfo); + + store.showAddComment(superdoc); + + expect(current).toHaveBeenCalledWith({ includeText: true }); + expect(superdoc.emit).toHaveBeenCalledWith('comments-update', { + type: comments_module_events.PENDING, + pendingSelection: selectionInfo, + }); + }); + + it('emits the pending event before inserting the pending mark', () => { + const { superdoc, insertComment } = makePendingSuperdoc({ + empty: false, + target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 1 } }] }, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + }); + + store.showAddComment(superdoc); + + // The snapshot is taken (and emitted) before the pending mark is + // inserted — otherwise the captured target could shift. + expect(insertComment).toHaveBeenCalled(); + expect(superdoc.emit.mock.invocationCallOrder[0]).toBeLessThan(insertComment.mock.invocationCallOrder[0]); + }); + + it('carries a null-target snapshot when the selection has no addressable target', () => { + const selectionInfo = { empty: true, target: null, activeMarks: [], activeCommentIds: [], activeChangeIds: [] }; + const { superdoc } = makePendingSuperdoc(selectionInfo); + + store.showAddComment(superdoc); + + const [, payload] = superdoc.emit.mock.calls[0]; + expect(payload.type).toBe(comments_module_events.PENDING); + expect(payload.pendingSelection).toEqual(selectionInfo); + expect(payload.pendingSelection.target).toBeNull(); + }); + + it('emits pendingSelection null when there is no active editor', () => { + const superdoc = { emit: vi.fn(), config: { isInternal: false }, activeEditor: undefined }; + + store.showAddComment(superdoc); + + const [, payload] = superdoc.emit.mock.calls[0]; + expect(payload.type).toBe(comments_module_events.PENDING); + expect(payload.pendingSelection).toBeNull(); + }); + }); + it('preserves the active floating instance when it belongs to the activated thread', () => { const setActiveCommentSpy = vi.fn(); const superdoc = { diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index 4f6faca482..e8be4c8b48 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -5,6 +5,7 @@ export { type CommandHandle, type CommandsHandle, type CommentAddress, + type CommentAnchorCapture, type CommentInfo, type CommentsHandle, type CommentsListQuery,