Skip to content

Commit 845fbfd

Browse files
committed
feat(superdoc): expose pendingSelection on the pending comments-update event (SD-3323)
The pending comments-update event now carries pendingSelection, the Document API selection snapshot captured before insertComment('pending') clears the live DOM selection. Consumers building a custom comment composer can forward it straight to ui.comments.createFromCapture instead of continuously tracking the selection ahead of the floating-bubble click. createFromCapture's input is widened to CommentAnchorCapture (anything carrying a target), so the SelectionInfo on the event passes directly without reconstructing a full SelectionCapture. The field is typed SelectionInfo (not SelectionCapture) since that is what the snapshot actually is. Related to IT-1113.
1 parent 1c70b9b commit 845fbfd

8 files changed

Lines changed: 149 additions & 2 deletions

File tree

packages/super-editor/src/ui/comments.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,32 @@ describe('ui.comments — actions route through editor.doc.*', () => {
379379
ui.destroy();
380380
});
381381

382+
it('createFromCapture accepts a Document API SelectionInfo (the pending-event shape) without a cast', () => {
383+
const { superdoc, mocks } = makeStubs();
384+
const ui = createSuperDocUI({ superdoc });
385+
386+
// Shape of `pendingSelection` on a pending `comments-update` event:
387+
// a SelectionInfo (target + text), NOT a full SelectionCapture (no
388+
// selectionTarget / quotedText). The widened CommentAnchorCapture
389+
// input accepts it directly — no cast here, so this also pins the
390+
// type widening at compile time.
391+
const pendingSelection = {
392+
empty: false,
393+
target: { kind: 'text' as const, segments: [{ blockId: 'p1', range: { start: 0, end: 4 } }] },
394+
activeMarks: [],
395+
activeCommentIds: [],
396+
activeChangeIds: [],
397+
text: 'word',
398+
};
399+
400+
const receipt = ui.comments.createFromCapture(pendingSelection, { text: 'from pending' });
401+
402+
expect(receipt.success).toBe(true);
403+
expect(mocks.create).toHaveBeenCalledWith({ target: pendingSelection.target, text: 'from pending' });
404+
405+
ui.destroy();
406+
});
407+
382408
it('reply forwards to comments.create with parentCommentId set and no target', () => {
383409
const { superdoc, mocks } = makeStubs();
384410
const ui = createSuperDocUI({ superdoc });

packages/super-editor/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type {
115115
UIToolbarCommandState,
116116

117117
// Comments
118+
CommentAnchorCapture,
118119
CommentsHandle,
119120
CommentsSlice,
120121

packages/super-editor/src/ui/types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,19 @@ export interface SelectionAnchorRectOptions {
11741174
*/
11751175
export type SelectionCapture = SelectionSlice;
11761176

1177+
/**
1178+
* The minimal anchor `ui.comments.createFromCapture` needs: anything
1179+
* carrying a positional `target`. Both a full {@link SelectionCapture}
1180+
* (from `ui.selection.capture()`) and the Document API
1181+
* {@link import('@superdoc/document-api').SelectionInfo} satisfy it —
1182+
* the latter is what a pending `comments-update` event exposes as
1183+
* `pendingSelection`, so consumers can forward it straight to
1184+
* `createFromCapture` without first reconstructing a `SelectionCapture`.
1185+
*/
1186+
export type CommentAnchorCapture = {
1187+
target: import('@superdoc/document-api').TextTarget | null;
1188+
};
1189+
11771190
/**
11781191
* Aggregate toolbar handle exposed on `ui.toolbar`. Compatible with
11791192
* `HeadlessToolbarController` from `superdoc/headless-toolbar` so the
@@ -1670,8 +1683,13 @@ export interface CommentsHandle {
16701683
* pass it here. Routes through `editor.doc.comments.create` with
16711684
* the captured `target`. Returns a `NO_OP` receipt when the capture
16721685
* lacks a positional target.
1686+
*
1687+
* Accepts any {@link CommentAnchorCapture} (only `target` is read),
1688+
* so the `pendingSelection` from a pending `comments-update` event —
1689+
* a Document API `SelectionInfo` — can be passed directly without
1690+
* reconstructing a full `SelectionCapture`.
16731691
*/
1674-
createFromCapture(capture: SelectionCapture, input: { text: string }): import('@superdoc/document-api').Receipt;
1692+
createFromCapture(capture: CommentAnchorCapture, input: { text: string }): import('@superdoc/document-api').Receipt;
16751693
/**
16761694
* Post a reply to an existing thread. Routes through
16771695
* `editor.doc.comments.create({ parentCommentId, text })`; the

packages/superdoc/src/core/types/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
FontsResolvedPayload,
3232
ListDefinitionsPayload,
3333
ProofingProvider,
34+
SelectionInfo,
3435
User,
3536
} from '@superdoc/super-editor';
3637

@@ -1416,6 +1417,19 @@ export interface SuperDocCommentsUpdatePayload {
14161417
comment?: Comment;
14171418
/** Per-field change set when the update is a mutation. */
14181419
changes?: Array<{ key: string; commentId: string; fileId?: string | null }>;
1420+
/**
1421+
* The Document API selection snapshot captured at the moment a
1422+
* `'pending'` comment was started, before the pending mark is
1423+
* inserted (which clears the live DOM selection). Present only on the
1424+
* `'pending'` event. Forward it straight to
1425+
* `ui.comments.createFromCapture(pendingSelection, { text })` to build
1426+
* the comment from a custom composer — it removes the need to track
1427+
* the selection yourself ahead of the floating-bubble click. A
1428+
* non-text or empty selection still yields a `SelectionInfo` whose
1429+
* `target` is `null`; the field itself is `null` only when there's no
1430+
* active editor.
1431+
*/
1432+
pendingSelection?: SelectionInfo | null;
14191433
}
14201434

14211435
export interface EditorTransactionEvent {

packages/superdoc/src/public/ui.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type {
4848
CommandHandle,
4949
CommandsHandle,
5050
CommentAddress,
51+
CommentAnchorCapture,
5152
CommentInfo,
5253
CommentsHandle,
5354
CommentsListQuery,

packages/superdoc/src/stores/comments-store.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,15 @@ export const useCommentsStore = defineStore('comments', () => {
857857
};
858858

859859
const showAddComment = (superdoc, targetClientY = null) => {
860-
const event = { type: COMMENT_EVENTS.PENDING };
860+
// Snapshot the selection BEFORE `insertComment('pending')` below adds
861+
// the pending mark and the floating-bubble click clears the live DOM
862+
// selection. This is the Document API `SelectionInfo` (carries
863+
// `target`); consumers forward it to `ui.comments.createFromCapture`
864+
// instead of tracking the selection themselves. A non-text/empty
865+
// selection still yields a `SelectionInfo` with `target: null`; the
866+
// value is `null` only when there's no active editor / selection API.
867+
const pendingSelection = superdoc.activeEditor?.doc?.selection?.current?.({ includeText: true }) ?? null;
868+
const event = { type: COMMENT_EVENTS.PENDING, pendingSelection };
861869
superdoc.emit('comments-update', event);
862870

863871
const selection = { ...superdocStore.activeSelection };

packages/superdoc/src/stores/comments-store.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,84 @@ describe('comments-store', () => {
280280
expect(setActiveCommentSpy).toHaveBeenCalledWith({ commentId: null });
281281
});
282282

283+
describe('showAddComment pendingSelection (IT-1113)', () => {
284+
const makePendingSuperdoc = (selectionInfo) => {
285+
const insertComment = vi.fn();
286+
const current = vi.fn(() => selectionInfo);
287+
return {
288+
superdoc: {
289+
emit: vi.fn(),
290+
config: { isInternal: false },
291+
activeEditor: {
292+
doc: { selection: { current } },
293+
commands: { insertComment },
294+
},
295+
},
296+
insertComment,
297+
current,
298+
};
299+
};
300+
301+
it('emits the pre-mutation Document API selection snapshot on the pending event', () => {
302+
const selectionInfo = {
303+
empty: false,
304+
target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 5 } }] },
305+
activeMarks: [],
306+
activeCommentIds: [],
307+
activeChangeIds: [],
308+
text: 'hello',
309+
};
310+
const { superdoc, current } = makePendingSuperdoc(selectionInfo);
311+
312+
store.showAddComment(superdoc);
313+
314+
expect(current).toHaveBeenCalledWith({ includeText: true });
315+
expect(superdoc.emit).toHaveBeenCalledWith('comments-update', {
316+
type: comments_module_events.PENDING,
317+
pendingSelection: selectionInfo,
318+
});
319+
});
320+
321+
it('emits the pending event before inserting the pending mark', () => {
322+
const { superdoc, insertComment } = makePendingSuperdoc({
323+
empty: false,
324+
target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 1 } }] },
325+
activeMarks: [],
326+
activeCommentIds: [],
327+
activeChangeIds: [],
328+
});
329+
330+
store.showAddComment(superdoc);
331+
332+
// The snapshot is taken (and emitted) before the pending mark is
333+
// inserted — otherwise the captured target could shift.
334+
expect(insertComment).toHaveBeenCalled();
335+
expect(superdoc.emit.mock.invocationCallOrder[0]).toBeLessThan(insertComment.mock.invocationCallOrder[0]);
336+
});
337+
338+
it('carries a null-target snapshot when the selection has no addressable target', () => {
339+
const selectionInfo = { empty: true, target: null, activeMarks: [], activeCommentIds: [], activeChangeIds: [] };
340+
const { superdoc } = makePendingSuperdoc(selectionInfo);
341+
342+
store.showAddComment(superdoc);
343+
344+
const [, payload] = superdoc.emit.mock.calls[0];
345+
expect(payload.type).toBe(comments_module_events.PENDING);
346+
expect(payload.pendingSelection).toEqual(selectionInfo);
347+
expect(payload.pendingSelection.target).toBeNull();
348+
});
349+
350+
it('emits pendingSelection null when there is no active editor', () => {
351+
const superdoc = { emit: vi.fn(), config: { isInternal: false }, activeEditor: undefined };
352+
353+
store.showAddComment(superdoc);
354+
355+
const [, payload] = superdoc.emit.mock.calls[0];
356+
expect(payload.type).toBe(comments_module_events.PENDING);
357+
expect(payload.pendingSelection).toBeNull();
358+
});
359+
});
360+
283361
it('preserves the active floating instance when it belongs to the activated thread', () => {
284362
const setActiveCommentSpy = vi.fn();
285363
const superdoc = {

packages/superdoc/src/ui.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
type CommandHandle,
66
type CommandsHandle,
77
type CommentAddress,
8+
type CommentAnchorCapture,
89
type CommentInfo,
910
type CommentsHandle,
1011
type CommentsListQuery,

0 commit comments

Comments
 (0)