Skip to content

Commit dc706f9

Browse files
authored
feat(document-api): ranges.scrollIntoView for text + entity targets (SD-2670) (#2928)
* feat(document-api): ranges.scrollIntoView for text + entity targets (SD-2670) Adds `editor.doc.ranges.scrollIntoView({ target, block?, behavior? })` so consumers can navigate the editor viewport to any document range without reaching into the deprecated ProseMirror view. Handles paginated, virtualized layouts — mounts the target page on demand before scrolling. - `target` accepts `TextAddress`, `TextTarget` (e.g. passed directly from `selection.current()`), or `EntityAddress` (scroll to a comment or tracked change by id). - `block` / `behavior` mirror the DOM `ScrollIntoViewOptions` shape and default to `center` / `smooth`. - Returns `Promise<{ success }>`. Async because virtualized pages may need to mount before the scroll completes; `success: false` when the target can't be resolved or the page-mount times out. - EntityAddress resolution piggybacks on existing internal resolvers (`resolveTrackedChange`, `listCommentAnchors`) — no new resolver added. Under the hood, the super-editor adapter resolves the target to a PM position and delegates to `PresentationEditor.scrollToPositionAsync`, which already handles virtualization correctly. Tests: 13 cases for the validator + delegation in document-api, 9 cases for the super-editor adapter helper (TextAddress, TextTarget first-segment, comment entity by commentId, comment entity by importedId fallback, tracked-change entity, missing-presentation fallback, option passthrough, adapter failure). New `testRangesScrollIntoView` section in consumer-typecheck exercises all three target shapes + the `Promise<ScrollIntoViewOutput>` return. Part of the SD-2667 drop-in assessment umbrella. Once this lands, the custom-sidebar-card-click → scroll-to-anchor flow in the drop-in example no longer needs a workspace-only hack. * fix(document-api): address PR review on ranges.scrollIntoView Three findings from PR 2928 review: 1. Unawaited Promise in invoke(): the new op was the first registry entry with a Promise output, but dynamic invoke() callers (e.g. CLI orchestrators) treated results synchronously and received a pending Promise instead of a `{ success }` payload. 2. Non-functional CLI exposure: registering in OPERATION_DEFINITIONS auto-surfaced `superdoc doc ranges scroll-into-view` through the CLI, but the CLI opens documents headlessly with no PresentationEditor, so the command could only ever return `{ success: false }`. 3. Story-blind tracked-change resolution: the EntityAddress path looked up tracked changes only in the host editor's body, ignoring the `story` metadata that trackChanges.list()/get() returns for header/footer/footnote/endnote anchors. Non-body targets silently returned `{ success: false }`. Fix: remove `ranges.scrollIntoView` from the RPC/CLI surface entirely (it is a browser-only UI side-effect, not a data-layer RPC) and delegate EntityAddress targets to `PresentationEditor.navigateTo`, which already has the story-aware activation path. - Dropped `ranges.scrollIntoView` from `OPERATION_DEFINITIONS`, `OperationRegistry`, `schemas.ts`, and the `invoke` dispatch table. Added it to `META_MEMBER_PATHS` in `check-contract-parity.ts` with a comment explaining the browser-only constraint — same precedent as `selection.onChange`. Direct calls through `editor.doc.ranges.scrollIntoView()` continue to work. - Rewrote `scrollRangeIntoView` to split cleanly by target kind: - EntityAddress → `presentation.navigateTo(target)`. The presentation editor handles page mounting, story activation, and alignment. The caller-provided `block` / `behavior` options are not applied here because `navigateTo` picks per-entity-type defaults that the document API shouldn't second-guess. - TextAddress / TextTarget → unchanged: resolve first segment to a PM position, call `scrollToPositionAsync` with caller options. - Removed `resolveTrackedChange` + `listCommentAnchors` imports from the adapter — the delegation to `navigateTo` makes them unnecessary. - Deleted the now-orphaned reference doc for the removed operation. - Added a test case covering tracked-change EntityAddress with a story field, verifying the full target (including story) reaches `navigateTo` unchanged. Tests: 10 adapter cases (5 text-path + 4 entity-path + 1 missing presentation) still pass; document-api 1384 pass; super-editor 11648 pass; consumer-typecheck clean; contract parity 386/386; outputs 426 files clean. * fix(document-api): honor success/false contract on text-path failures (SD-2670) PR 2928 follow-up. - F2: wrap the TextAddress / TextTarget path in try/catch so `resolveTextTarget` throws (ambiguous block id) and `scrollToPositionAsync` rejections resolve to `{ success: false }` instead of propagating to the caller. The entity path already did this; the text path should match. - Added 2 tests covering the thrown-resolver case and the rejected-scroll case. - Updated JSDoc to call out the virtualized-non-body-entity limitation flagged in F1. That limitation lives in `PresentationEditor.#navigateToTrackedChange` — its non-body paths (`#activateTrackedChangeStorySurface`, `#scrollToRenderedTrackedChange`) both require rendered DOM candidates, with no equivalent of the body path's `setCursorById` + `scrollToPositionAsync` pre-mount flow. Tracked as SD-2750 since the fix needs body-side reference resolution inside the presentation editor, not the adapter. Tests: 12 adapter cases pass (was 10, +2 for F2).
1 parent 6e603cd commit dc706f9

16 files changed

Lines changed: 802 additions & 8 deletions

File tree

apps/docs/document-engine/sdks.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
571571
| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. |
572572
| `doc.markdownToFragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. |
573573
| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. |
574+
| `doc.extract` | `extract` | Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). |
574575
| `doc.clearContent` | `clear-content` | Clear all document body content, leaving a single empty paragraph. |
575576
| `doc.insert` | `insert` | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
576577
| `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. |
@@ -1031,6 +1032,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
10311032
| `doc.get_html` | `get-html` | Extract the document content as an HTML string. |
10321033
| `doc.markdown_to_fragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. |
10331034
| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. |
1035+
| `doc.extract` | `extract` | Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). |
10341036
| `doc.clear_content` | `clear-content` | Clear all document body content, leaving a single empty paragraph. |
10351037
| `doc.insert` | `insert` | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
10361038
| `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. |

packages/document-api/scripts/check-contract-parity.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,19 @@ import { OPERATION_DEFINITIONS } from '../src/contract/operation-definitions.js'
2121
import { OPERATION_REFERENCE_DOC_PATH_MAP } from '../src/contract/reference-doc-map.js';
2222
import { buildDispatchTable } from '../src/invoke/invoke.js';
2323

24-
/** Meta-methods and helper methods on DocumentApi that are not contract operations. */
25-
const META_MEMBER_PATHS = ['invoke', ...REFERENCE_OPERATION_ALIASES.map((alias) => alias.memberPath)];
24+
/**
25+
* Meta-methods and helper methods on DocumentApi that are not contract
26+
* operations. `ranges.scrollIntoView` is a browser-only UI side-effect
27+
* (scrolls the viewport via the presentation editor) — it has no
28+
* headless implementation, so it is intentionally excluded from the RPC
29+
* dispatch surface and the CLI command catalog. Direct calls through
30+
* `editor.doc.ranges.scrollIntoView()` are still supported.
31+
*/
32+
const META_MEMBER_PATHS = [
33+
'invoke',
34+
'ranges.scrollIntoView',
35+
...REFERENCE_OPERATION_ALIASES.map((alias) => alias.memberPath),
36+
];
2637

2738
function collectFunctionMemberPaths(value: unknown, prefix = ''): string[] {
2839
if (!value || typeof value !== 'object') return [];

packages/document-api/src/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ export type {
2626
RangeBlockPreview,
2727
RangePreview,
2828
RangeResolverAdapter,
29+
ScrollIntoViewInput,
30+
ScrollIntoViewOutput,
31+
RangeScrollAdapter,
2932
} from './ranges/index.js';
30-
export { executeResolveRange } from './ranges/index.js';
33+
export { executeResolveRange, executeScrollIntoView } from './ranges/index.js';
3134
export type { HeaderFootersAdapter, HeaderFootersApi } from './header-footers/header-footers.js';
3235
export * from './header-footers/header-footers.types.js';
3336
export type { ClearContentAdapter, ClearContentInput } from './clear-content/clear-content.js';
@@ -125,7 +128,14 @@ import {
125128
import type { InsertInput } from './insert/insert.js';
126129
import { executeDelete } from './delete/delete.js';
127130
import { executeResolveRange } from './ranges/resolve.js';
128-
import type { RangeResolverAdapter, ResolveRangeInput, ResolveRangeOutput } from './ranges/ranges.types.js';
131+
import { executeScrollIntoView } from './ranges/scroll-into-view.js';
132+
import type {
133+
RangeResolverAdapter,
134+
ResolveRangeInput,
135+
ResolveRangeOutput,
136+
ScrollIntoViewInput,
137+
ScrollIntoViewOutput,
138+
} from './ranges/ranges.types.js';
129139
import { executeInsert } from './insert/insert.js';
130140
import type { ListsAdapter, ListsApi } from './lists/lists.js';
131141
import type {
@@ -1471,10 +1481,19 @@ export interface MutationsApi {
14711481

14721482
export interface RangesApi {
14731483
resolve(input: ResolveRangeInput): ResolveRangeOutput;
1484+
/**
1485+
* Scroll the editor viewport so the target range is visible. Handles
1486+
* paginated, virtualized layouts by mounting the target page on demand.
1487+
* Async — resolves once the scroll settles (or the page-mount timeout
1488+
* expires). Target accepts `TextAddress`, `TextTarget`, or `EntityAddress`
1489+
* (comment / tracked change by id).
1490+
*/
1491+
scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput>;
14741492
}
14751493

14761494
export interface RangesAdapter {
14771495
resolve(input: ResolveRangeInput): ResolveRangeOutput;
1496+
scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput>;
14781497
}
14791498

14801499
export interface QueryAdapter {
@@ -3120,6 +3139,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
31203139
resolve(input: ResolveRangeInput): ResolveRangeOutput {
31213140
return executeResolveRange(adapters.ranges, input);
31223141
},
3142+
scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput> {
3143+
return executeScrollIntoView(adapters.ranges, input);
3144+
},
31233145
},
31243146
mutations: {
31253147
preview(input: MutationsPreviewInput): MutationsPreviewOutput {

packages/document-api/src/ranges/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ export type {
88
RangeBlockPreview,
99
RangePreview,
1010
RangeResolverAdapter,
11+
ScrollIntoViewInput,
12+
ScrollIntoViewOutput,
13+
RangeScrollAdapter,
1114
} from './ranges.types.js';
1215
export { executeResolveRange } from './resolve.js';
16+
export { executeScrollIntoView } from './scroll-into-view.js';

packages/document-api/src/ranges/ranges.types.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* into a contiguous `SelectionTarget` + mutation-ready `ref`.
77
*/
88

9-
import type { SelectionTarget, SelectionPoint } from '../types/address.js';
9+
import type { SelectionTarget, SelectionPoint, TextAddress, TextTarget, EntityAddress } from '../types/address.js';
1010
import type { BlockNodeType } from '../types/base.js';
1111
import type { StoryLocator } from '../types/story.types.js';
1212

@@ -120,3 +120,44 @@ export interface ResolveRangeOutput {
120120
export interface RangeResolverAdapter {
121121
resolve(input: ResolveRangeInput): ResolveRangeOutput;
122122
}
123+
124+
// ---------------------------------------------------------------------------
125+
// scrollIntoView
126+
// ---------------------------------------------------------------------------
127+
128+
/**
129+
* Input for `ranges.scrollIntoView` — scrolls the editor viewport so the
130+
* given text target is visible. Handles paginated, virtualized layouts by
131+
* mounting the target page if it isn't yet in the DOM.
132+
*/
133+
export interface ScrollIntoViewInput {
134+
/**
135+
* The target to scroll to. Accepts:
136+
* - {@link TextAddress} — single-block text range
137+
* - {@link TextTarget} — multi-segment text target
138+
* - {@link EntityAddress} — reference to a comment or tracked change by id
139+
* (e.g. `{ kind: 'entity', entityType: 'trackedChange', entityId: 'tc_123' }`)
140+
*/
141+
target: TextAddress | TextTarget | EntityAddress;
142+
/** Alignment within the viewport. Defaults to `'center'`. */
143+
block?: 'start' | 'center' | 'end' | 'nearest';
144+
/** Scroll behavior. Defaults to `'smooth'`. */
145+
behavior?: 'auto' | 'smooth';
146+
}
147+
148+
/**
149+
* Result of `ranges.scrollIntoView`.
150+
* `success: false` when the target couldn't be resolved or a page failed to
151+
* mount within the navigation timeout.
152+
*/
153+
export interface ScrollIntoViewOutput {
154+
success: boolean;
155+
}
156+
157+
/**
158+
* Adapter method for `ranges.scrollIntoView`. Async because virtualized
159+
* pages may need to mount before the scroll completes.
160+
*/
161+
export interface RangeScrollAdapter {
162+
scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput>;
163+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it, mock } from 'bun:test';
2+
import { executeScrollIntoView } from './scroll-into-view.js';
3+
import type { RangeScrollAdapter, ScrollIntoViewInput, ScrollIntoViewOutput } from './ranges.types.js';
4+
5+
function makeAdapter(output: ScrollIntoViewOutput = { success: true }): RangeScrollAdapter & {
6+
scrollIntoView: ReturnType<typeof mock>;
7+
} {
8+
const scrollIntoView = mock(async () => output);
9+
return { scrollIntoView } as unknown as RangeScrollAdapter & { scrollIntoView: ReturnType<typeof mock> };
10+
}
11+
12+
async function expectValidationError(fn: () => Promise<unknown>, code: string, messageMatch: string): Promise<void> {
13+
try {
14+
await fn();
15+
throw new Error(`expected ${code}, nothing thrown`);
16+
} catch (err: unknown) {
17+
const e = err as { name?: string; code?: string; message?: string };
18+
expect(e.name).toBe('DocumentApiValidationError');
19+
expect(e.code).toBe(code);
20+
expect(e.message ?? '').toContain(messageMatch);
21+
}
22+
}
23+
24+
describe('executeScrollIntoView — validation', () => {
25+
it('rejects a null / undefined input', async () => {
26+
const adapter = makeAdapter();
27+
await expectValidationError(
28+
() => executeScrollIntoView(adapter, null as unknown as ScrollIntoViewInput),
29+
'INVALID_INPUT',
30+
'non-null object',
31+
);
32+
await expectValidationError(
33+
() => executeScrollIntoView(adapter, undefined as unknown as ScrollIntoViewInput),
34+
'INVALID_INPUT',
35+
'non-null object',
36+
);
37+
});
38+
39+
it('rejects inputs with unknown fields', async () => {
40+
const adapter = makeAdapter();
41+
await expectValidationError(
42+
() =>
43+
executeScrollIntoView(adapter, {
44+
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
45+
somethingElse: true,
46+
} as unknown as ScrollIntoViewInput),
47+
'INVALID_INPUT',
48+
'Unknown field',
49+
);
50+
});
51+
52+
it('rejects when target is missing', async () => {
53+
const adapter = makeAdapter();
54+
await expectValidationError(
55+
() => executeScrollIntoView(adapter, {} as unknown as ScrollIntoViewInput),
56+
'INVALID_TARGET',
57+
'requires a target',
58+
);
59+
});
60+
61+
it('rejects when target is malformed', async () => {
62+
const adapter = makeAdapter();
63+
await expectValidationError(
64+
() => executeScrollIntoView(adapter, { target: { kind: 'nope' } } as unknown as ScrollIntoViewInput),
65+
'INVALID_TARGET',
66+
'TextAddress, TextTarget, or EntityAddress',
67+
);
68+
});
69+
70+
it('rejects entity targets with unknown entityType', async () => {
71+
const adapter = makeAdapter();
72+
await expectValidationError(
73+
() =>
74+
executeScrollIntoView(adapter, {
75+
target: { kind: 'entity', entityType: 'mystery', entityId: 'x_1' },
76+
} as unknown as ScrollIntoViewInput),
77+
'INVALID_TARGET',
78+
'TextAddress, TextTarget, or EntityAddress',
79+
);
80+
});
81+
82+
it('rejects entity targets with empty entityId', async () => {
83+
const adapter = makeAdapter();
84+
await expectValidationError(
85+
() =>
86+
executeScrollIntoView(adapter, {
87+
target: { kind: 'entity', entityType: 'comment', entityId: '' },
88+
} as unknown as ScrollIntoViewInput),
89+
'INVALID_TARGET',
90+
'TextAddress, TextTarget, or EntityAddress',
91+
);
92+
});
93+
94+
it('rejects block outside the allowed enum', async () => {
95+
const adapter = makeAdapter();
96+
await expectValidationError(
97+
() =>
98+
executeScrollIntoView(adapter, {
99+
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
100+
block: 'top' as 'start',
101+
}),
102+
'INVALID_INPUT',
103+
'block must be',
104+
);
105+
});
106+
107+
it('rejects behavior outside the allowed enum', async () => {
108+
const adapter = makeAdapter();
109+
await expectValidationError(
110+
() =>
111+
executeScrollIntoView(adapter, {
112+
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
113+
behavior: 'instant' as 'auto',
114+
}),
115+
'INVALID_INPUT',
116+
'behavior must be',
117+
);
118+
});
119+
});
120+
121+
describe('executeScrollIntoView — delegation', () => {
122+
it('accepts a TextAddress target and forwards it unchanged', async () => {
123+
const adapter = makeAdapter();
124+
const input: ScrollIntoViewInput = {
125+
target: { kind: 'text', blockId: 'p1', range: { start: 3, end: 9 } },
126+
};
127+
const out = await executeScrollIntoView(adapter, input);
128+
expect(out).toEqual({ success: true });
129+
expect(adapter.scrollIntoView).toHaveBeenCalledWith(input);
130+
});
131+
132+
it('accepts a multi-segment TextTarget target', async () => {
133+
const adapter = makeAdapter();
134+
const input: ScrollIntoViewInput = {
135+
target: {
136+
kind: 'text',
137+
segments: [
138+
{ blockId: 'p1', range: { start: 0, end: 5 } },
139+
{ blockId: 'p2', range: { start: 0, end: 3 } },
140+
],
141+
},
142+
block: 'start',
143+
behavior: 'auto',
144+
};
145+
const out = await executeScrollIntoView(adapter, input);
146+
expect(out).toEqual({ success: true });
147+
expect(adapter.scrollIntoView).toHaveBeenCalledWith(input);
148+
});
149+
150+
it('accepts an EntityAddress (comment) target', async () => {
151+
const adapter = makeAdapter();
152+
const input: ScrollIntoViewInput = {
153+
target: { kind: 'entity', entityType: 'comment', entityId: 'c_1' },
154+
};
155+
await executeScrollIntoView(adapter, input);
156+
expect(adapter.scrollIntoView).toHaveBeenCalledWith(input);
157+
});
158+
159+
it('accepts an EntityAddress (trackedChange) target', async () => {
160+
const adapter = makeAdapter();
161+
const input: ScrollIntoViewInput = {
162+
target: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc_1' },
163+
};
164+
await executeScrollIntoView(adapter, input);
165+
expect(adapter.scrollIntoView).toHaveBeenCalledWith(input);
166+
});
167+
168+
it('returns whatever the adapter returns (e.g. success: false)', async () => {
169+
const adapter = makeAdapter({ success: false });
170+
const out = await executeScrollIntoView(adapter, {
171+
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
172+
});
173+
expect(out).toEqual({ success: false });
174+
});
175+
});

0 commit comments

Comments
 (0)