Skip to content

Commit 4d85c51

Browse files
authored
Merge pull request #3562 from superdoc-dev/caio/sd-3310-scrollintoview
feat(ui): scroll a content control into view by id (SD-3310)
2 parents 915dbbe + f0278ea commit 4d85c51

8 files changed

Lines changed: 453 additions & 4 deletions

File tree

apps/docs/editor/custom-ui/content-controls.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,16 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti
3939
| Full live list and active stack | `ui.contentControls.observe()` / `getSnapshot()` |
4040
| Read one control | `ui.contentControls.get({ id })` |
4141
| Position your UI | `ui.contentControls.getRect({ id })` |
42+
| Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` |
4243
| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` |
4344
| Change content, tags, or locks | `editor.doc.contentControls.*` |
4445

4546
`active` is the innermost control. For nested controls (an inline field inside a block clause), `activePath` carries the full stack, innermost first, so you don't also need `observe()` just to read the nesting.
4647

48+
`scrollIntoView` resolves the control's position from the document, so it works even when the control is on a page that hasn't rendered yet (the page mounts, then scrolls). It scrolls only - it does not move the cursor into the control.
49+
4750
## Current limits
4851

49-
- No built-in scroll-to-control. Read the position with `getRect()` and scroll your container.
5052
- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events.
5153
- No focus-by-id helper. Clicking a control in the document still drives selection.
5254

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3815,6 +3815,74 @@ export class PresentationEditor extends EventEmitter {
38153815
return this.scrollToPosition(pos, options);
38163816
}
38173817

3818+
/**
3819+
* Scroll a content control (SDT field/clause) into view by its id.
3820+
*
3821+
* Model-aware: the control's position is resolved from the document
3822+
* model, not the painted DOM, so this works even when the control sits
3823+
* on a not-yet-rendered (virtualized) page — `scrollToPositionAsync`
3824+
* mounts the page first, then scrolls. This is why it cannot reuse the
3825+
* paint-only `getEntityRects` rect path.
3826+
*
3827+
* Scroll-only: it does NOT move the selection or place the caret inside
3828+
* the control. Focusing/activating a control is a separate concern.
3829+
*
3830+
* v1 is body-only: it searches the body editor, and `scrollToPositionAsync`
3831+
* only scrolls in body mode, so a control inside a header/footer/note
3832+
* story does not resolve and returns `false`.
3833+
*
3834+
* @returns `true` once scrolled; `false` when the id is empty/unknown,
3835+
* the control is in a non-body story, or no editor is available.
3836+
*/
3837+
async scrollContentControlIntoView(
3838+
entityId: string,
3839+
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3840+
): Promise<boolean> {
3841+
const editor = this.#editor;
3842+
if (!editor || typeof entityId !== 'string' || entityId.length === 0) return false;
3843+
3844+
let found: { pos: number; node: ReturnType<typeof editor.state.doc.nodeAt> } | null = null;
3845+
editor.state.doc.descendants((node, pos) => {
3846+
if (found) return false;
3847+
const name = node.type?.name;
3848+
// Normalize the node id to a string before comparing. The id a
3849+
// consumer passes comes from the list / painted `data-sdt-id` (always
3850+
// a string), but the PM attr can be numeric, so a strict `===` would
3851+
// miss it. Matches the painted-DOM (`getRect`) id convention.
3852+
if ((name === 'structuredContent' || name === 'structuredContentBlock') && String(node.attrs?.id) === entityId) {
3853+
found = { pos, node };
3854+
return false;
3855+
}
3856+
return true;
3857+
});
3858+
if (!found) return false;
3859+
3860+
// Resolve the first *text* position inside the control. Only text
3861+
// positions reliably map to a layout fragment; wrapper boundaries
3862+
// (block, paragraph, run) sit between fragments and make
3863+
// `scrollToPositionAsync` fail. A deep `descendants` walk handles
3864+
// inline (`run > text`) and block (`paragraph > run > text`) nesting
3865+
// uniformly. `descendants` yields each child's position relative to
3866+
// the node's content, so the absolute position is `found.pos + 1 + rel`.
3867+
// Scroll-only: this does NOT move the selection or focus.
3868+
let contentPos = found.pos + 1;
3869+
let textFound = false;
3870+
found.node?.descendants((child, rel) => {
3871+
if (textFound) return false;
3872+
if (child.isText) {
3873+
contentPos = found.pos + 1 + rel;
3874+
textFound = true;
3875+
return false;
3876+
}
3877+
return true;
3878+
});
3879+
3880+
return this.scrollToPositionAsync(contentPos, {
3881+
behavior: options.behavior ?? 'smooth',
3882+
block: options.block ?? 'center',
3883+
});
3884+
}
3885+
38183886
/**
38193887
* Scrolls a specific page into view.
38203888
*

packages/super-editor/src/ui/content-controls.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,51 @@ describe('ui.contentControls handle (SD-3157)', () => {
351351
ui.destroy();
352352
});
353353

354+
it('scrollIntoView({ id }) returns { success: false } for an empty or missing id without touching the presentation', async () => {
355+
const { superdoc, editor } = makeStub({ items: [makeItem('sdt-1')] });
356+
const ui = createSuperDocUI({ superdoc });
357+
// Attach after construction: a `presentationEditor` present during the
358+
// toolbar-snapshot build would need the full presentation interface.
359+
const scroll = vi.fn().mockResolvedValue(true);
360+
(editor as { presentationEditor?: unknown }).presentationEditor = { scrollContentControlIntoView: scroll };
361+
362+
expect(await ui.contentControls.scrollIntoView({ id: '' })).toEqual({ success: false });
363+
// @ts-expect-error exercising the runtime guard for a missing id
364+
expect(await ui.contentControls.scrollIntoView({})).toEqual({ success: false });
365+
expect(scroll).not.toHaveBeenCalled();
366+
367+
ui.destroy();
368+
});
369+
370+
it('scrollIntoView({ id }) returns { success: false } when the presentation layer is not ready', async () => {
371+
// The stub editor has no `presentationEditor` (e.g. SSR / pre-mount).
372+
const { superdoc } = makeStub({ items: [makeItem('sdt-1')] });
373+
const ui = createSuperDocUI({ superdoc });
374+
375+
expect(await ui.contentControls.scrollIntoView({ id: 'sdt-1' })).toEqual({ success: false });
376+
377+
ui.destroy();
378+
});
379+
380+
it('scrollIntoView({ id }) delegates to the presentation scroll with center/smooth defaults and maps the boolean to { success }', async () => {
381+
const { superdoc, editor } = makeStub({ items: [makeItem('sdt-1')] });
382+
const ui = createSuperDocUI({ superdoc });
383+
const scroll = vi.fn().mockResolvedValue(true);
384+
(editor as { presentationEditor?: unknown }).presentationEditor = { scrollContentControlIntoView: scroll };
385+
386+
expect(await ui.contentControls.scrollIntoView({ id: 'sdt-1' })).toEqual({ success: true });
387+
expect(scroll).toHaveBeenCalledWith('sdt-1', { block: 'center', behavior: 'smooth' });
388+
389+
// Explicit options pass through; a falsy presentation result maps to failure.
390+
scroll.mockResolvedValueOnce(false);
391+
expect(await ui.contentControls.scrollIntoView({ id: 'sdt-1', block: 'start', behavior: 'auto' })).toEqual({
392+
success: false,
393+
});
394+
expect(scroll).toHaveBeenLastCalledWith('sdt-1', { block: 'start', behavior: 'auto' });
395+
396+
ui.destroy();
397+
});
398+
354399
it('observe receives the snapshot value directly (parallel to comments/trackChanges)', async () => {
355400
// `observe` is the value-shaped alias of `subscribe`. The demo
356401
// (`field-chip.ts`) consumes it directly, so an explicit test

packages/super-editor/src/ui/create-super-doc-ui.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2250,6 +2250,43 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
22502250
target: { kind: 'entity', entityType: 'contentControl', entityId: id },
22512251
});
22522252
},
2253+
async scrollIntoView({
2254+
id,
2255+
block,
2256+
behavior,
2257+
}: {
2258+
id: string;
2259+
block?: 'start' | 'center' | 'end' | 'nearest';
2260+
behavior?: 'auto' | 'smooth';
2261+
}): Promise<ScrollIntoViewOutput> {
2262+
if (typeof id !== 'string' || id.length === 0) return { success: false };
2263+
// Resolve through the host editor — `presentationEditor` lives on the
2264+
// body/host, not a routed child story editor. Same posture as
2265+
// `viewport.getRect` / `runScrollIntoView`. The model-aware scroll is
2266+
// body-only, so a control in a header/footer/note resolves to a no-op
2267+
// `{ success: false }`. We call the presentation method directly rather
2268+
// than routing a content-control target through `viewport.scrollIntoView`
2269+
// — content controls are UI-local and deliberately absent from the
2270+
// Document API `ScrollIntoViewInput` address union (mirrors `getRect`).
2271+
const editor = resolveHostEditor(superdoc);
2272+
const presentation = editor?.presentationEditor as
2273+
| {
2274+
scrollContentControlIntoView?: (
2275+
id: string,
2276+
opts: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: 'auto' | 'smooth' },
2277+
) => Promise<boolean>;
2278+
}
2279+
| null
2280+
| undefined;
2281+
if (!presentation || typeof presentation.scrollContentControlIntoView !== 'function') {
2282+
return { success: false };
2283+
}
2284+
const ok = await presentation.scrollContentControlIntoView(id, {
2285+
block: block ?? 'center',
2286+
behavior: behavior ?? 'smooth',
2287+
});
2288+
return { success: Boolean(ok) };
2289+
},
22532290
};
22542291

22552292
// Resolve a metadata id (= the SDT's w:tag) to the SDT's content-

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,10 @@ export interface ContentControlsSlice {
462462
* directly, matching the architectural rule that this handle is a UI
463463
* surface, not a parallel mutation contract.
464464
*
465-
* The handle does not include `scrollIntoView` in v1: that path
466-
* widens `ui.viewport.scrollIntoView` and is a separate slice from
467-
* `ui.viewport.getRect` (SD-3156).
465+
* The handle includes `scrollIntoView` via a dedicated model-aware
466+
* path. It does NOT widen `ui.viewport.scrollIntoView`: content controls
467+
* stay UI-local and out of the Document API address union, mirroring how
468+
* `getRect` resolves a content control through a UI-local address.
468469
*/
469470
export interface ContentControlsHandle {
470471
/** Snapshot the current content-controls slice synchronously. */
@@ -496,6 +497,28 @@ export interface ContentControlsHandle {
496497
* failure reason).
497498
*/
498499
getRect(input: { id: string }): ViewportRectResult;
500+
/**
501+
* Scroll the content control identified by `id` into view. The
502+
* control's position is resolved from the document model (not the
503+
* painted DOM), so it works even when the control sits on a
504+
* not-yet-rendered (virtualized) page — the page is mounted, then
505+
* scrolled. Scroll-only: it does not move the selection or place the
506+
* caret inside the control.
507+
*
508+
* Returns the same `ScrollIntoViewOutput` shape as
509+
* `ui.viewport.scrollIntoView`: `{ success: true }` once scrolled, or
510+
* `{ success: false }` when `id` is empty/unknown or the presentation
511+
* layer isn't ready. `block` defaults to `'center'`, `behavior` to
512+
* `'smooth'`.
513+
*
514+
* v1 is body-only: a control inside a header/footer/note story does
515+
* not resolve and returns `{ success: false }`.
516+
*/
517+
scrollIntoView(input: {
518+
id: string;
519+
block?: 'start' | 'center' | 'end' | 'nearest';
520+
behavior?: 'auto' | 'smooth';
521+
}): Promise<import('@superdoc/document-api').ScrollIntoViewOutput>;
499522
}
500523

501524
/**
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* SD-3310 regression for imported Word-authored content controls.
3+
*
4+
* `nda-template.docx` is the contract-templates demo's fixture: 13 real
5+
* Word SDTs (7 inline smart fields + 6 block clauses). It is copied here
6+
* (not loaded from demos/) so the behavior suite isn't coupled to demo
7+
* paths.
8+
*
9+
* Confirms `ui.contentControls.scrollIntoView` resolves controls imported
10+
* from a real .docx (not just programmatically-created ones) and scrolls
11+
* them into view from an off-screen start.
12+
*/
13+
14+
import path from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
import { test, expect } from '../../fixtures/superdoc.js';
17+
18+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
19+
const NDA = path.resolve(__dirname, 'fixtures/nda-template.docx');
20+
21+
test.use({ viewport: { width: 1000, height: 360 } });
22+
23+
type Item = { id: string; kind: string; text?: string };
24+
25+
async function snapshotItems(page: import('@playwright/test').Page): Promise<Item[]> {
26+
return page.evaluate(() => {
27+
const ui = (window as any).__bootSuperDocUI?.();
28+
if (!ui) return [];
29+
return ui.contentControls.getSnapshot().items.map((it: any) => ({ id: it.id, kind: it.kind, text: it.text }));
30+
});
31+
}
32+
33+
async function probeVisible(page: import('@playwright/test').Page, id: string) {
34+
return page.evaluate((sdtId) => {
35+
const el = document.querySelector<HTMLElement>(`[data-sdt-id="${sdtId}"]`);
36+
if (!el) return { painted: false, inViewport: false };
37+
const r = el.getBoundingClientRect();
38+
return { painted: true, inViewport: r.top >= 0 && r.top <= window.innerHeight };
39+
}, id);
40+
}
41+
42+
async function scrollTo(page: import('@playwright/test').Page, id: string): Promise<{ success: boolean }> {
43+
return page.evaluate(async (sdtId) => {
44+
const ui = (window as any).__bootSuperDocUI?.();
45+
if (!ui) return { success: false };
46+
return ui.contentControls.scrollIntoView({ id: sdtId, block: 'center', behavior: 'auto' });
47+
}, id);
48+
}
49+
50+
async function scrollContainerTo(page: import('@playwright/test').Page, edge: 'top' | 'bottom'): Promise<void> {
51+
await page.evaluate((to) => {
52+
let node: HTMLElement | null = document.querySelector<HTMLElement>('.presentation-editor__pages');
53+
let scroller: HTMLElement | null = null;
54+
while (node) {
55+
if (node.scrollHeight > node.clientHeight + 4) {
56+
scroller = node;
57+
break;
58+
}
59+
node = node.parentElement;
60+
}
61+
const target = to === 'top' ? 0 : 1_000_000;
62+
if (scroller) scroller.scrollTop = target;
63+
else window.scrollTo(0, target);
64+
}, edge);
65+
}
66+
67+
test('@behavior SD-3310: scrolls real imported NDA-template controls (first field + last clause) into view', async ({
68+
superdoc,
69+
}) => {
70+
await superdoc.loadDocument(NDA);
71+
await superdoc.waitForStable();
72+
73+
const items = await snapshotItems(superdoc.page);
74+
// Sanity: the fixture's controls are visible to the handle.
75+
expect(items.length).toBeGreaterThanOrEqual(6);
76+
77+
const first = items[0]; // top-most (an inline smart field)
78+
const last = items[items.length - 1]; // bottom-most (a block clause)
79+
expect(first.id).toBeTruthy();
80+
expect(last.id).toBeTruthy();
81+
82+
// Bottom clause: scroll to top so it's off-screen, then scroll it in.
83+
await scrollContainerTo(superdoc.page, 'top');
84+
await superdoc.waitForStable();
85+
expect((await probeVisible(superdoc.page, last.id)).inViewport).toBe(false);
86+
expect((await scrollTo(superdoc.page, last.id)).success).toBe(true);
87+
await superdoc.waitForStable();
88+
expect(await probeVisible(superdoc.page, last.id)).toEqual({ painted: true, inViewport: true });
89+
90+
// Top field: scroll to bottom so it's off-screen, then scroll it in.
91+
await scrollContainerTo(superdoc.page, 'bottom');
92+
await superdoc.waitForStable();
93+
expect((await probeVisible(superdoc.page, first.id)).inViewport).toBe(false);
94+
expect((await scrollTo(superdoc.page, first.id)).success).toBe(true);
95+
await superdoc.waitForStable();
96+
expect(await probeVisible(superdoc.page, first.id)).toEqual({ painted: true, inViewport: true });
97+
});

0 commit comments

Comments
 (0)