Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/document-api/reference/_generated-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,5 +1031,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "cdb0b02e84f6eb7f4db962c177d082e0f89ec48517abc775736a3d17e4da9ba8"
"sourceHash": "f8bc3155490940ebbfa68ecf81df0787bf5d7e4c0930bdaa599144974d093619"
}
24 changes: 23 additions & 1 deletion apps/docs/document-api/reference/selection/current.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Returns a SelectionInfo with `empty`, `target` (TextTarget or null), `activeMark

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `activeChangeIds` | string[] | yes | |
| `activeCommentIds` | string[] | yes | |
| `activeMarks` | string[] | yes | |
| `empty` | boolean | yes | |
| `target` | TextTarget \\| null | yes | One of: TextTarget, null |
Expand All @@ -49,6 +51,12 @@ Returns a SelectionInfo with `empty`, `target` (TextTarget or null), `activeMark

```json
{
"activeChangeIds": [
"example"
],
"activeCommentIds": [
"example"
],
"activeMarks": [
"example"
],
Expand Down Expand Up @@ -99,6 +107,18 @@ Returns a SelectionInfo with `empty`, `target` (TextTarget or null), `activeMark
{
"additionalProperties": false,
"properties": {
"activeChangeIds": {
"items": {
"type": "string"
},
"type": "array"
},
"activeCommentIds": {
"items": {
"type": "string"
},
"type": "array"
},
"activeMarks": {
"items": {
"type": "string"
Expand All @@ -125,7 +145,9 @@ Returns a SelectionInfo with `empty`, `target` (TextTarget or null), `activeMark
"required": [
"empty",
"target",
"activeMarks"
"activeMarks",
"activeCommentIds",
"activeChangeIds"
],
"type": "object"
}
Expand Down
4 changes: 3 additions & 1 deletion packages/document-api/src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5295,9 +5295,11 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
empty: { type: 'boolean' },
target: { oneOf: [textTargetSchema, { type: 'null' }] },
activeMarks: arraySchema({ type: 'string' }),
activeCommentIds: arraySchema({ type: 'string' }),
activeChangeIds: arraySchema({ type: 'string' }),
text: { type: 'string' },
},
['empty', 'target', 'activeMarks'],
['empty', 'target', 'activeMarks', 'activeCommentIds', 'activeChangeIds'],
),
},

Expand Down
26 changes: 26 additions & 0 deletions packages/document-api/src/selection/selection.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,34 @@ export interface SelectionInfo {
* Active marks at the caret or across the selection. Names are
* ProseMirror mark type names (e.g. `'bold'`, `'italic'`, `'link'`).
* Use these to drive toolbar active-state rendering.
*
* `activeMarks` uses **intersection** semantics — a name is present
* only when every character in the selection carries that mark. This
* matches Word/Google Docs toolbar behavior.
*/
activeMarks: string[];
/**
* Comment IDs whose `commentMark` overlaps any part of the current
* selection (or covers the caret when empty). Use to drive a
* floating "comment here" hint, highlight the active sidebar card,
* or disable a "new comment" button when the selection already
* covers an existing comment.
*
* **Union** semantics: an id is present when *any* character in the
* selection carries that comment, not when every character does.
* Multiple overlapping comments produce multiple ids.
*/
activeCommentIds: string[];
/**
* Tracked-change IDs whose `trackInsert` / `trackDelete` /
* `trackFormat` mark overlaps any part of the current selection.
* Same union semantics as {@link activeCommentIds}.
*
* Use to drive review-sidebar highlighting and next/previous
* navigation without resolving every change individually via
* `trackChanges.list()`.
*/
activeChangeIds: string[];
/**
* Quoted text of the selection. Populated only when `includeText: true`.
* Undefined otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ type NodeOptions = {
attrs?: Record<string, unknown>;
/** Mark names applied to this node (only used for text nodes). */
markNames?: string[];
/**
* Marks with attrs (commentMark, trackInsert, etc). Coexists with
* `markNames` — both end up in `node.marks`. Use this for tests that
* exercise per-mark attribute-driven id collection.
*/
marksWithAttrs?: Array<{ name: string; attrs: Record<string, unknown> }>;
};

function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode {
Expand Down Expand Up @@ -50,7 +56,10 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options:
child(index: number) {
return children[index]!;
},
marks: (options.markNames ?? []).map((name) => ({ type: { name } })),
marks: [
...(options.markNames ?? []).map((name) => ({ type: { name }, attrs: {} as Record<string, unknown> })),
...(options.marksWithAttrs ?? []).map((m) => ({ type: { name: m.name }, attrs: m.attrs })),
],
// `nodesBetween` walks the whole subtree. A minimal correct
// implementation for our test shapes: visit self first, then recurse
// into children with the right child-position accounting.
Expand Down Expand Up @@ -154,7 +163,7 @@ describe('resolveCurrentSelectionInfo', () => {
it('returns an empty info with null target when the editor has no state', () => {
const editor = { state: null } as unknown as Editor;
const info = resolveCurrentSelectionInfo(editor, {});
expect(info).toEqual({ empty: true, target: null, activeMarks: [] });
expect(info).toEqual({ empty: true, target: null, activeMarks: [], activeCommentIds: [], activeChangeIds: [] });
});

it('projects a single-block selection into a one-segment TextTarget', () => {
Expand Down Expand Up @@ -324,3 +333,178 @@ describe('resolveCurrentSelectionInfo', () => {
expect(elapsed).toBeLessThan(500);
});
});
// ---------------------------------------------------------------------------
// activeCommentIds / activeChangeIds (SD-2792)
// ---------------------------------------------------------------------------

/**
* Marked-text helper that lets each run carry attribute-bearing marks
* (commentMark with commentId, trackInsert/Delete/Format with id).
*/
function entityMarkedTextBlock(
blockId: string,
runs: Array<{
text: string;
marks?: string[];
marksWithAttrs?: Array<{ name: string; attrs: Record<string, unknown> }>;
}>,
): ProseMirrorNode {
const children = runs.map((r) =>
createNode('text', [], {
text: r.text,
markNames: r.marks ?? [],
marksWithAttrs: r.marksWithAttrs ?? [],
}),
);
return createNode('paragraph', children, {
isBlock: true,
inlineContent: true,
attrs: { sdBlockId: blockId },
});
}

describe('resolveCurrentSelectionInfo > entity ids', () => {
it('collects commentIds from commentMarks across the selection (union)', () => {
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'Hello ', marksWithAttrs: [{ name: 'commentMark', attrs: { commentId: 'c1' } }] },
{
text: 'world',
marksWithAttrs: [
{ name: 'commentMark', attrs: { commentId: 'c1' } },
{ name: 'commentMark', attrs: { commentId: 'c2' } },
],
},
]),
]);
// Select the whole text "Hello world" (PM positions 2..13).
const editor = makeEditor(docNode, { from: 2, to: 13 });

const info = resolveCurrentSelectionInfo(editor, {});

expect([...info.activeCommentIds].sort()).toEqual(['c1', 'c2']);
expect(info.activeChangeIds).toEqual([]);
});

it('collects changeIds from trackInsert/trackDelete/trackFormat marks', () => {
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'inserted ', marksWithAttrs: [{ name: 'trackInsert', attrs: { id: 'tc1' } }] },
{ text: 'deleted ', marksWithAttrs: [{ name: 'trackDelete', attrs: { id: 'tc2' } }] },
{ text: 'reformat', marksWithAttrs: [{ name: 'trackFormat', attrs: { id: 'tc3' } }] },
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 27 });

const info = resolveCurrentSelectionInfo(editor, {});

expect([...info.activeChangeIds].sort()).toEqual(['tc1', 'tc2', 'tc3']);
expect(info.activeCommentIds).toEqual([]);
});

it('reports both comment and change ids when a span carries both', () => {
const docNode = doc([
entityMarkedTextBlock('p1', [
{
text: 'reviewed',
marksWithAttrs: [
{ name: 'commentMark', attrs: { commentId: 'c1' } },
{ name: 'trackInsert', attrs: { id: 'tc1' } },
],
},
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 10 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeCommentIds).toEqual(['c1']);
expect(info.activeChangeIds).toEqual(['tc1']);
});

it('returns empty id arrays when no entity marks overlap the selection', () => {
const docNode = doc([entityMarkedTextBlock('p1', [{ text: 'Plain text', marks: ['bold'] }])]);
const editor = makeEditor(docNode, { from: 2, to: 12 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeCommentIds).toEqual([]);
expect(info.activeChangeIds).toEqual([]);
expect(info.activeMarks).toEqual(['bold']);
});

it('uses union semantics, not intersection (one comment touching part of the selection counts)', () => {
// Run 1 has comment c1; run 2 is plain. activeMarks would not include
// a "bold" if it only touched run 1, but activeCommentIds should
// include c1 because we use union semantics.
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'commented', marksWithAttrs: [{ name: 'commentMark', attrs: { commentId: 'c1' } }] },
{ text: ' tail', marks: [] },
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 16 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeCommentIds).toEqual(['c1']);
});

it('resolves comment ids from importedId / w:id when commentId is absent (legacy DOCX imports)', () => {
// Imported / legacy comment marks may carry the id on `importedId`
// or `w:id` instead of the post-import canonical `commentId`. The
// resolver must honor the same fallback chain the rest of the
// adapter graph uses (`resolveCommentIdFromAttrs`).
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'imported ', marksWithAttrs: [{ name: 'commentMark', attrs: { importedId: 'imp-1' } }] },
{ text: 'legacy', marksWithAttrs: [{ name: 'commentMark', attrs: { 'w:id': 'leg-2' } }] },
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 17 });

const info = resolveCurrentSelectionInfo(editor, {});

expect([...info.activeCommentIds].sort()).toEqual(['imp-1', 'leg-2']);
});

it('collects entity ids from inline atom nodes (image, tab, line break with marks)', () => {
// A range selection over an image with a comment should still
// surface the comment id. Inline leaf nodes carry marks just like
// text runs do; restricting to `isText` would skip them and leave
// sidebar highlighting empty for image-only / atom-only selections.
const imageWithComment = createNode('image', [], {
isInline: true,
isLeaf: true,
attrs: { src: 'cat.png' },
marksWithAttrs: [{ name: 'commentMark', attrs: { commentId: 'c-img' } }],
});
const block = createNode('paragraph', [imageWithComment], {
isBlock: true,
inlineContent: true,
attrs: { sdBlockId: 'p1' },
});
const docNode = doc([block]);
// Doc layout: doc[0..5], paragraph[1..4] (content [2..3]), image atom[2..3].
// Select the image atom span: PM [2, 3].
const editor = makeEditor(docNode, { from: 2, to: 3 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeCommentIds).toEqual(['c-img']);
});

it('empty arrays survive a JSON round-trip (serialization-stable shape)', () => {
// Schema and dispatch tests assume the SelectionInfo output is JSON-
// serializable with stable field presence. Empty arrays should
// serialize and parse back as empty arrays, not be elided.
const docNode = doc([textBlock('p1', 'Hello')]);
const editor = makeEditor(docNode, { from: 2, to: 7 });

const info = resolveCurrentSelectionInfo(editor, {});
const roundTripped = JSON.parse(JSON.stringify(info));

expect(roundTripped.activeCommentIds).toEqual([]);
expect(roundTripped.activeChangeIds).toEqual([]);
});
});
Loading
Loading