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
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": "fa717df8cc013f9703b118596afe4cbbf655fe73f2e4e856588844110998ee2e"
"sourceHash": "20f0873e29e8589387d0776cc53e6eea8d5fce102a3eba9a47a183c49b5f0b13"
}
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 @@ -611,7 +611,7 @@
const trackedChangeAddressSchema = ref('TrackedChangeAddress');
const entityAddressSchema = ref('EntityAddress');
const selectionTargetSchema = ref('SelectionTarget');
const targetLocatorSchema = ref('TargetLocator');

Check warning on line 614 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'targetLocatorSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
const deleteBehaviorSchema = ref('DeleteBehavior');
const resolvedHandleSchema = ref('ResolvedHandle');
const pageInfoSchema = ref('PageInfo');
Expand Down Expand Up @@ -886,7 +886,7 @@
text: { type: 'string' },
});

const nodeInfoSchema: JsonSchema = {

Check warning on line 889 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'nodeInfoSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
type: 'object',
required: ['nodeType', 'kind'],
properties: {
Expand All @@ -902,7 +902,7 @@
additionalProperties: false,
};

const matchContextSchema = objectSchema(

Check warning on line 905 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'matchContextSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
address: nodeAddressSchema,
snippet: { type: 'string' },
Expand All @@ -913,7 +913,7 @@
['address', 'snippet', 'highlightRange'],
);

const unknownNodeDiagnosticSchema = objectSchema(

Check warning on line 916 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'unknownNodeDiagnosticSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
message: { type: 'string' },
address: nodeAddressSchema,
Expand Down Expand Up @@ -5299,9 +5299,11 @@
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 @@ -3,6 +3,23 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model';
import type { Editor } from '../../core/Editor.js';
import { resolveCurrentSelectionInfo, subscribeToSelection } from './selection-info-resolver.js';

// Stub `groupTrackedChanges` so tests don't need a fully PM-shaped
// editor with `editor.state.doc.textBetween` and the tracked-change
// mark walker. Each test that exercises tracked-change ids configures
// the raw → canonical mapping it expects.
const groupTrackedChangesMock = vi.hoisted(() =>
vi.fn(() => [] as Array<{ rawId: string; id: string; from: number; to: number }>),
);
vi.mock('./tracked-change-resolver.js', () => ({
groupTrackedChanges: groupTrackedChangesMock,
}));

const setTrackedChangeMapping = (mappings: Array<{ rawId: string; canonical: string }>) => {
groupTrackedChangesMock.mockReturnValue(
mappings.map((m) => ({ rawId: m.rawId, id: m.canonical, from: 0, to: 0 })) as never,
);
};

// ---------------------------------------------------------------------------
// PM node stub builder
//
Expand All @@ -21,6 +38,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 +73,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 +180,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 @@ -325,6 +351,212 @@ describe('resolveCurrentSelectionInfo', () => {
});
});

// ---------------------------------------------------------------------------
// 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 (translated through canonical resolver)', () => {
// Raw mark ids and canonical Document API ids differ: the canonical
// id is a derived hash from `groupTrackedChanges`. We mock that map
// so the resolver sees raw 'tc1' / 'tc2' / 'tc3' and returns the
// canonical 'tcA' / 'tcB' / 'tcC' that consumers see in
// `trackChanges.list().items[].id`.
setTrackedChangeMapping([
{ rawId: 'tc1', canonical: 'tcA' },
{ rawId: 'tc2', canonical: 'tcB' },
{ rawId: 'tc3', canonical: 'tcC' },
]);
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(['tcA', 'tcB', 'tcC']);
expect(info.activeCommentIds).toEqual([]);
});

it('drops raw change ids that have no canonical mapping (defensive)', () => {
// Raw id present in the document but missing from groupTrackedChanges
// (mid-construction editor, or a mark that wasn't grouped). Leaking
// the raw id past the resolver would silently produce no-match
// highlights in consumer sidebars; drop it instead.
setTrackedChangeMapping([{ rawId: 'tc1', canonical: 'tcA' }]);
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'mapped ', marksWithAttrs: [{ name: 'trackInsert', attrs: { id: 'tc1' } }] },
{ text: 'orphan', marksWithAttrs: [{ name: 'trackInsert', attrs: { id: 'orphan-id' } }] },
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 14 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeChangeIds).toEqual(['tcA']);
});

it('dedupes canonical ids when two raw ids map to the same canonical (paired tracked changes)', () => {
// Tracked replace produces paired insert + delete halves whose
// raw mark ids both group to a single canonical id. A range
// selection across both halves must surface the canonical id
// once, not twice — otherwise sidebar counts and union-driven
// highlights would double-count the change.
setTrackedChangeMapping([
{ rawId: 'tc1-insert', canonical: 'tcA' },
{ rawId: 'tc1-delete', canonical: 'tcA' },
]);
const docNode = doc([
entityMarkedTextBlock('p1', [
{ text: 'inserted ', marksWithAttrs: [{ name: 'trackInsert', attrs: { id: 'tc1-insert' } }] },
{ text: 'deleted', marksWithAttrs: [{ name: 'trackDelete', attrs: { id: 'tc1-delete' } }] },
]),
]);
const editor = makeEditor(docNode, { from: 2, to: 16 });

const info = resolveCurrentSelectionInfo(editor, {});

expect(info.activeChangeIds).toEqual(['tcA']);
});

it('reports both comment and change ids when a span carries both', () => {
setTrackedChangeMapping([{ rawId: 'tc1', canonical: 'tcA' }]);
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(['tcA']);
});

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 comment adapter graph uses
// (`resolveCommentIdFromAttrs`); without it,
// `selection.current().activeCommentIds` would stay empty over a
// run that `comments.list()` reports as a real comment.
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('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([]);
});
});

// ---------------------------------------------------------------------------
// subscribeToSelection
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading