Skip to content

Commit 3bbdbe5

Browse files
authored
Merge pull request #3570 from superdoc-dev/caio/sd-3311-viewport-observe
feat(ui): viewport geometry-invalidation hook ui.viewport.observe (SD-3311)
2 parents 3fbfa3f + 8a2b0d5 commit 3bbdbe5

6 files changed

Lines changed: 351 additions & 15 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
@@ -40,16 +40,18 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti
4040
| Read one control | `ui.contentControls.get({ id })` |
4141
| Position your UI | `ui.contentControls.getRect({ id })` |
4242
| Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` |
43+
| Re-anchor your UI when the page moves | `ui.viewport.observe(() => ...)` |
4344
| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` |
4445
| Change content, tags, or locks | `editor.doc.contentControls.*` |
4546

4647
`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.
4748

4849
`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.
4950

51+
`ui.viewport.observe` is the single signal for "your `getRect()` coordinates may be stale, re-query": it fires (coalesced, once per frame) on scroll, resize, zoom, and layout reflow, so an overlay anchored with `getRect` stays glued without hand-wiring those events yourself.
52+
5053
## Current limits
5154

52-
- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events.
5355
- No focus-by-id helper. Clicking a control in the document still drives selection.
5456

5557
## See also
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* SD-3311 regression: the field chip must stay anchored to its active control
5+
* after a geometry change that fires NO scroll event (zoom). The chip is a
6+
* fixed-position overlay positioned from `ui.contentControls.getRect()`. Today
7+
* field-chip only re-anchors on active-change / scroll / resize, so a zoom
8+
* leaves it stranded (verified: ~230px drift). This is RED until
9+
* `ui.viewport.observe()` lands and field-chip re-queries on it.
10+
*
11+
* Runs only for the contract-templates demo (the shared suite runs once per DEMO).
12+
*/
13+
14+
test.use({ viewport: { width: 1280, height: 800 } });
15+
16+
test('field chip stays anchored to its control after a zoom (no-scroll geometry change)', async ({ page }) => {
17+
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
18+
19+
await page.route('**/ingest.superdoc.dev/**', (r) =>
20+
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
21+
);
22+
await page.goto('/');
23+
await page.waitForFunction(
24+
() => {
25+
const ui = (window as any).__demo?.state?.ui;
26+
return !!ui && ui.contentControls.getSnapshot().items.length > 0;
27+
},
28+
null,
29+
{ timeout: 30_000 },
30+
);
31+
32+
// Activate the first inline smart field so the chip appears and anchors.
33+
await page.waitForSelector('.superdoc-structured-content-inline[data-sdt-id]');
34+
await page.locator('.superdoc-structured-content-inline[data-sdt-id]').first().click();
35+
await page.locator('.sd-field-chip').waitFor({ state: 'visible', timeout: 10_000 });
36+
37+
// Horizontal gap between the chip's left edge and its active control's left
38+
// edge. positionChip sets chip.left = control.left, so this is ~0 when anchored.
39+
const probe = () =>
40+
page.evaluate(() => {
41+
const ui = (window as any).__demo.state.ui;
42+
const activeId = ui.contentControls.getSnapshot().activeId as string | null;
43+
const chip = document.querySelector<HTMLElement>('.sd-field-chip');
44+
const ctrl = activeId ? document.querySelector<HTMLElement>(`[data-sdt-id="${activeId}"]`) : null;
45+
if (!chip || !ctrl) return null;
46+
const c = chip.getBoundingClientRect();
47+
const k = ctrl.getBoundingClientRect();
48+
return { dxLeft: Math.abs(c.left - k.left), ctrlLeft: Math.round(k.left) };
49+
});
50+
51+
const before = await probe();
52+
expect(before, 'chip + active control both resolve').not.toBeNull();
53+
expect(before!.dxLeft, 'chip starts anchored to the control').toBeLessThanOrEqual(2);
54+
55+
// Zoom: a geometry change with no scroll event.
56+
await page.evaluate(() => (window as any).__demo.superdoc.setZoom(150));
57+
58+
// Poll for the settled state: the control has moved (zoom applied) AND the
59+
// chip has re-anchored to it. Polling absorbs the rAF/repaint delay between
60+
// the geometry change and the viewport.observe -> positionChip re-query.
61+
// Without the SD-3311 fix this stays "drift:~230" and times out.
62+
await expect
63+
.poll(
64+
async () => {
65+
const p = await probe();
66+
if (!p) return 'no-probe';
67+
if (p.ctrlLeft === before!.ctrlLeft) return 'control-not-moved';
68+
return p.dxLeft <= 2 ? 'anchored' : `drift:${Math.round(p.dxLeft)}`;
69+
},
70+
{ timeout: 6_000 },
71+
)
72+
.toBe('anchored');
73+
});

demos/contract-templates/src/field-chip.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,11 @@ export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: Smar
115115
positionChip();
116116
};
117117

118-
const onScrollOrResize = () => positionChip();
118+
// Re-anchor whenever the viewport geometry changes. ui.viewport.observe is
119+
// the single signal for this - it fires on scroll, resize, zoom, and
120+
// layout/pagination reflow, so we catch the zoom / reflow cases that
121+
// hand-wired window scroll + resize listeners miss (SD-3311).
122+
const onViewportChange = () => positionChip();
119123

120124
// SD-3232: the active control comes from the public SuperDoc event. The
121125
// payload includes the SdtRef (id + tag), so we can narrow to smart
@@ -150,13 +154,11 @@ export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: Smar
150154
};
151155

152156
superdoc.on('content-control:active-change', onActiveChange);
153-
window.addEventListener('scroll', onScrollOrResize, true);
154-
window.addEventListener('resize', onScrollOrResize);
157+
const unobserveViewport = ui.viewport.observe(onViewportChange);
155158

156159
return () => {
157160
superdoc.off('content-control:active-change', onActiveChange);
158-
window.removeEventListener('scroll', onScrollOrResize, true);
159-
window.removeEventListener('resize', onScrollOrResize);
161+
unobserveViewport();
160162
chipEl.remove();
161163
};
162164
}

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import type {
6666
ViewportPositionAtInput,
6767
ViewportPositionHit,
6868
ViewportHandle,
69+
ViewportGeometryEvent,
6970
ViewportRect,
7071
ViewportRectResult,
7172
} from './types.js';
@@ -958,6 +959,81 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
958959
};
959960
};
960961

962+
// --- Viewport geometry-invalidation signal (ui.viewport.observe) ---------
963+
// One "your cached getRect() coords may be stale, re-query" notification.
964+
// Sources: layout/pagination repaints (post-paint), zoom, and DOM scroll /
965+
// resize. rAF-coalesced, so a burst collapses to one notification per frame.
966+
const geometryListeners = new Set<(event: ViewportGeometryEvent) => void>();
967+
const pendingGeometryReasons = new Set<Exclude<ViewportGeometryEvent['reason'], 'mixed'>>();
968+
let geometryRaf: number | null = null;
969+
let zoomPending = false;
970+
971+
const cancelGeometryFrame = () => {
972+
if (geometryRaf == null) return;
973+
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(geometryRaf);
974+
else clearTimeout(geometryRaf as unknown as ReturnType<typeof setTimeout>);
975+
geometryRaf = null;
976+
};
977+
const flushGeometry = () => {
978+
geometryRaf = null;
979+
const reasons = [...pendingGeometryReasons];
980+
pendingGeometryReasons.clear();
981+
if (geometryListeners.size === 0 || reasons.length === 0) return;
982+
const reason: ViewportGeometryEvent['reason'] = reasons.length === 1 ? reasons[0] : 'mixed';
983+
[...geometryListeners].forEach((listener) => {
984+
try {
985+
listener({ reason });
986+
} catch {
987+
// Isolate a faulty consumer; the others still get notified.
988+
}
989+
});
990+
};
991+
const scheduleGeometry = (reason: Exclude<ViewportGeometryEvent['reason'], 'mixed'>) => {
992+
if (geometryListeners.size === 0) return;
993+
pendingGeometryReasons.add(reason);
994+
if (geometryRaf != null) return;
995+
geometryRaf =
996+
typeof requestAnimationFrame === 'function'
997+
? requestAnimationFrame(flushGeometry)
998+
: (setTimeout(flushGeometry, 0) as unknown as number);
999+
};
1000+
// zoomChange fires *before* the re-render, so notifying then would hand
1001+
// consumers stale rects. Tag the next post-paint layout flush as 'zoom'.
1002+
const onGeometryZoom = () => {
1003+
zoomPending = true;
1004+
};
1005+
const onGeometryLayout = () => {
1006+
if (zoomPending) {
1007+
zoomPending = false;
1008+
scheduleGeometry('zoom');
1009+
} else {
1010+
scheduleGeometry('layout');
1011+
}
1012+
};
1013+
const onWindowScrollGeometry = () => scheduleGeometry('scroll');
1014+
const onWindowResizeGeometry = () => scheduleGeometry('resize');
1015+
let domGeometryAttached = false;
1016+
const attachDomGeometryListeners = () => {
1017+
if (domGeometryAttached || typeof window === 'undefined') return;
1018+
domGeometryAttached = true;
1019+
// Capture phase so scrolls inside the editor's own scroll container
1020+
// (scroll events don't bubble) are still observed.
1021+
window.addEventListener('scroll', onWindowScrollGeometry, true);
1022+
window.addEventListener('resize', onWindowResizeGeometry);
1023+
};
1024+
const detachDomGeometryListeners = () => {
1025+
if (!domGeometryAttached || typeof window === 'undefined') return;
1026+
domGeometryAttached = false;
1027+
window.removeEventListener('scroll', onWindowScrollGeometry, true);
1028+
window.removeEventListener('resize', onWindowResizeGeometry);
1029+
};
1030+
teardown.push(() => {
1031+
detachDomGeometryListeners();
1032+
cancelGeometryFrame();
1033+
geometryListeners.clear();
1034+
pendingGeometryReasons.clear();
1035+
});
1036+
9611037
// Wire SuperDoc-instance events. The wrapper-side bus (editorCreate /
9621038
// document-mode-change / zoomChange) is the only path for some of
9631039
// these signals today; if the wrapper migrates them to the editor
@@ -966,8 +1042,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
9661042
SUPERDOC_EVENTS.forEach((name) => {
9671043
superdoc.on?.(name, scheduleNotify);
9681044
});
1045+
// zoom drives geometry (post-paint, tagged via onGeometryLayout) — separate
1046+
// from the slice recompute that SUPERDOC_EVENTS triggers.
1047+
superdoc.on?.('zoomChange', onGeometryZoom);
9691048
teardown.push(() => {
9701049
SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify));
1050+
superdoc.off?.('zoomChange', onGeometryZoom);
9711051
});
9721052
}
9731053

@@ -1089,8 +1169,18 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
10891169
PRESENTATION_EVENTS.forEach((name) => {
10901170
next.on?.(name, onPresentationChange);
10911171
});
1172+
// Geometry-only: layout repaints move painted rects without a body
1173+
// `transaction`. Drive the viewport geometry signal, NOT the slice
1174+
// recompute (which would re-attach editor listeners on every repaint).
1175+
// Listen to `layoutUpdated` only: `paginationUpdate` is emitted
1176+
// back-to-back with the same payload for the same paint
1177+
// (PresentationEditor.ts:6491-6492), so subscribing to both would
1178+
// double-count one repaint — a zoom would coalesce to 'mixed' instead of
1179+
// 'zoom'. `layoutUpdated` alone covers every repaint.
1180+
next.on?.('layoutUpdated', onGeometryLayout);
10921181
currentPresentationTeardown = () => {
10931182
PRESENTATION_EVENTS.forEach((name) => next.off?.(name, onPresentationChange));
1183+
next.off?.('layoutUpdated', onGeometryLayout);
10941184
};
10951185
};
10961186

@@ -1927,6 +2017,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
19272017
};
19282018
},
19292019

2020+
observe(listener: (event: ViewportGeometryEvent) => void): () => void {
2021+
geometryListeners.add(listener);
2022+
// Attach the DOM scroll/resize listeners only while someone is observing.
2023+
if (geometryListeners.size === 1) attachDomGeometryListeners();
2024+
return () => {
2025+
if (!geometryListeners.delete(listener)) return;
2026+
if (geometryListeners.size === 0) {
2027+
detachDomGeometryListeners();
2028+
cancelGeometryFrame();
2029+
pendingGeometryReasons.clear();
2030+
zoomPending = false;
2031+
}
2032+
};
2033+
},
2034+
19302035
async scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput> {
19312036
return runScrollIntoView(input);
19322037
},

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -714,11 +714,10 @@ export interface SuperDocUI {
714714
selection: SelectionHandle;
715715

716716
/**
717-
* Viewport domain — imperative geometry queries for sticky-card /
718-
* floating-toolbar placement against painted entities and ranges.
719-
* No subscription substrate — viewport rects are read on-demand by
720-
* the consumer (e.g. on hover, on scroll, on layout-change events
721-
* the consumer already listens to). Browser-only by definition.
717+
* Viewport domain — geometry queries for sticky-card / floating-toolbar
718+
* placement against painted entities and ranges, plus
719+
* {@link ViewportHandle.observe} to learn when those rects may have moved.
720+
* Browser-only by definition.
722721
*/
723722
viewport: ViewportHandle;
724723

@@ -1831,17 +1830,38 @@ export type ViewportRectResult =
18311830
};
18321831

18331832
/**
1834-
* Imperative viewport-geometry surface. No subscription primitive —
1835-
* rects are read on demand. Consumers who need to reflow on layout
1836-
* change typically already listen to a `transaction` / `paint` /
1837-
* `scroll` event upstream and call `getRect` from there.
1833+
* Reason a {@link ViewportHandle.observe} notification fired. `'mixed'`
1834+
* when more than one change coalesced into the same animation frame.
18381835
*/
1836+
export type ViewportGeometryReason = 'layout' | 'zoom' | 'scroll' | 'resize' | 'mixed';
1837+
1838+
/**
1839+
* Payload for {@link ViewportHandle.observe}. Intentionally minimal: the
1840+
* signal means "your cached `getRect()` coordinates may be stale, re-query" -
1841+
* it carries no geometry.
1842+
*/
1843+
export interface ViewportGeometryEvent {
1844+
reason: ViewportGeometryReason;
1845+
}
1846+
18391847
export interface ViewportHandle {
18401848
/**
18411849
* Look up the painted rectangle(s) of an entity or text range in
18421850
* viewport coordinates. Synchronous — no DOM mutation required.
18431851
*/
18441852
getRect(input: ViewportGetRectInput): ViewportRectResult;
1853+
/**
1854+
* Subscribe to viewport geometry invalidation. The listener fires (once
1855+
* per animation frame, coalesced) after anything that can move painted
1856+
* rectangles: layout / pagination repaints, zoom, and DOM scroll / resize.
1857+
* It carries no coordinates — re-query {@link getRect} for the entities you
1858+
* care about. Returns an unsubscribe.
1859+
*
1860+
* This is the single signal overlays should listen to instead of
1861+
* hand-wiring scroll + resize + layout + zoom (and still missing cases like
1862+
* reflow and zoom, which fire no scroll event).
1863+
*/
1864+
observe(listener: (event: ViewportGeometryEvent) => void): () => void;
18451865
/**
18461866
* Scroll the viewport so the target is visible. Browser-only by
18471867
* definition: drives `presentation.navigateTo()` for entity targets

0 commit comments

Comments
 (0)