diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index 1ca5dea4a3..101c3e57d3 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -40,6 +40,7 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti | Read one control | `ui.contentControls.get({ id })` | | Position your UI | `ui.contentControls.getRect({ id })` | | Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` | +| Re-anchor your UI when the page moves | `ui.viewport.observe(() => ...)` | | Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` | | Change content, tags, or locks | `editor.doc.contentControls.*` | @@ -47,9 +48,10 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti `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. +`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. + ## Current limits -- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events. - No focus-by-id helper. Clicking a control in the document still drives selection. ## See also diff --git a/demos/__tests__/contract-templates-chip-anchor.spec.ts b/demos/__tests__/contract-templates-chip-anchor.spec.ts new file mode 100644 index 0000000000..cf16ec6558 --- /dev/null +++ b/demos/__tests__/contract-templates-chip-anchor.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +/** + * SD-3311 regression: the field chip must stay anchored to its active control + * after a geometry change that fires NO scroll event (zoom). The chip is a + * fixed-position overlay positioned from `ui.contentControls.getRect()`. Today + * field-chip only re-anchors on active-change / scroll / resize, so a zoom + * leaves it stranded (verified: ~230px drift). This is RED until + * `ui.viewport.observe()` lands and field-chip re-queries on it. + * + * Runs only for the contract-templates demo (the shared suite runs once per DEMO). + */ + +test.use({ viewport: { width: 1280, height: 800 } }); + +test('field chip stays anchored to its control after a zoom (no-scroll geometry change)', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => { + const ui = (window as any).__demo?.state?.ui; + return !!ui && ui.contentControls.getSnapshot().items.length > 0; + }, + null, + { timeout: 30_000 }, + ); + + // Activate the first inline smart field so the chip appears and anchors. + await page.waitForSelector('.superdoc-structured-content-inline[data-sdt-id]'); + await page.locator('.superdoc-structured-content-inline[data-sdt-id]').first().click(); + await page.locator('.sd-field-chip').waitFor({ state: 'visible', timeout: 10_000 }); + + // Horizontal gap between the chip's left edge and its active control's left + // edge. positionChip sets chip.left = control.left, so this is ~0 when anchored. + const probe = () => + page.evaluate(() => { + const ui = (window as any).__demo.state.ui; + const activeId = ui.contentControls.getSnapshot().activeId as string | null; + const chip = document.querySelector('.sd-field-chip'); + const ctrl = activeId ? document.querySelector(`[data-sdt-id="${activeId}"]`) : null; + if (!chip || !ctrl) return null; + const c = chip.getBoundingClientRect(); + const k = ctrl.getBoundingClientRect(); + return { dxLeft: Math.abs(c.left - k.left), ctrlLeft: Math.round(k.left) }; + }); + + const before = await probe(); + expect(before, 'chip + active control both resolve').not.toBeNull(); + expect(before!.dxLeft, 'chip starts anchored to the control').toBeLessThanOrEqual(2); + + // Zoom: a geometry change with no scroll event. + await page.evaluate(() => (window as any).__demo.superdoc.setZoom(150)); + + // Poll for the settled state: the control has moved (zoom applied) AND the + // chip has re-anchored to it. Polling absorbs the rAF/repaint delay between + // the geometry change and the viewport.observe -> positionChip re-query. + // Without the SD-3311 fix this stays "drift:~230" and times out. + await expect + .poll( + async () => { + const p = await probe(); + if (!p) return 'no-probe'; + if (p.ctrlLeft === before!.ctrlLeft) return 'control-not-moved'; + return p.dxLeft <= 2 ? 'anchored' : `drift:${Math.round(p.dxLeft)}`; + }, + { timeout: 6_000 }, + ) + .toBe('anchored'); +}); diff --git a/demos/contract-templates/src/field-chip.ts b/demos/contract-templates/src/field-chip.ts index 53b328e5e6..a6311ce52e 100644 --- a/demos/contract-templates/src/field-chip.ts +++ b/demos/contract-templates/src/field-chip.ts @@ -115,7 +115,11 @@ export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: Smar positionChip(); }; - const onScrollOrResize = () => positionChip(); + // Re-anchor whenever the viewport geometry changes. ui.viewport.observe is + // the single signal for this - it fires on scroll, resize, zoom, and + // layout/pagination reflow, so we catch the zoom / reflow cases that + // hand-wired window scroll + resize listeners miss (SD-3311). + const onViewportChange = () => positionChip(); // SD-3232: the active control comes from the public SuperDoc event. The // 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 }; superdoc.on('content-control:active-change', onActiveChange); - window.addEventListener('scroll', onScrollOrResize, true); - window.addEventListener('resize', onScrollOrResize); + const unobserveViewport = ui.viewport.observe(onViewportChange); return () => { superdoc.off('content-control:active-change', onActiveChange); - window.removeEventListener('scroll', onScrollOrResize, true); - window.removeEventListener('resize', onScrollOrResize); + unobserveViewport(); chipEl.remove(); }; } diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 2af4ece7d6..e6bbd5efeb 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -66,6 +66,7 @@ import type { ViewportPositionAtInput, ViewportPositionHit, ViewportHandle, + ViewportGeometryEvent, ViewportRect, ViewportRectResult, } from './types.js'; @@ -958,6 +959,81 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }; + // --- Viewport geometry-invalidation signal (ui.viewport.observe) --------- + // One "your cached getRect() coords may be stale, re-query" notification. + // Sources: layout/pagination repaints (post-paint), zoom, and DOM scroll / + // resize. rAF-coalesced, so a burst collapses to one notification per frame. + const geometryListeners = new Set<(event: ViewportGeometryEvent) => void>(); + const pendingGeometryReasons = new Set>(); + let geometryRaf: number | null = null; + let zoomPending = false; + + const cancelGeometryFrame = () => { + if (geometryRaf == null) return; + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(geometryRaf); + else clearTimeout(geometryRaf as unknown as ReturnType); + geometryRaf = null; + }; + const flushGeometry = () => { + geometryRaf = null; + const reasons = [...pendingGeometryReasons]; + pendingGeometryReasons.clear(); + if (geometryListeners.size === 0 || reasons.length === 0) return; + const reason: ViewportGeometryEvent['reason'] = reasons.length === 1 ? reasons[0] : 'mixed'; + [...geometryListeners].forEach((listener) => { + try { + listener({ reason }); + } catch { + // Isolate a faulty consumer; the others still get notified. + } + }); + }; + const scheduleGeometry = (reason: Exclude) => { + if (geometryListeners.size === 0) return; + pendingGeometryReasons.add(reason); + if (geometryRaf != null) return; + geometryRaf = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(flushGeometry) + : (setTimeout(flushGeometry, 0) as unknown as number); + }; + // zoomChange fires *before* the re-render, so notifying then would hand + // consumers stale rects. Tag the next post-paint layout flush as 'zoom'. + const onGeometryZoom = () => { + zoomPending = true; + }; + const onGeometryLayout = () => { + if (zoomPending) { + zoomPending = false; + scheduleGeometry('zoom'); + } else { + scheduleGeometry('layout'); + } + }; + const onWindowScrollGeometry = () => scheduleGeometry('scroll'); + const onWindowResizeGeometry = () => scheduleGeometry('resize'); + let domGeometryAttached = false; + const attachDomGeometryListeners = () => { + if (domGeometryAttached || typeof window === 'undefined') return; + domGeometryAttached = true; + // Capture phase so scrolls inside the editor's own scroll container + // (scroll events don't bubble) are still observed. + window.addEventListener('scroll', onWindowScrollGeometry, true); + window.addEventListener('resize', onWindowResizeGeometry); + }; + const detachDomGeometryListeners = () => { + if (!domGeometryAttached || typeof window === 'undefined') return; + domGeometryAttached = false; + window.removeEventListener('scroll', onWindowScrollGeometry, true); + window.removeEventListener('resize', onWindowResizeGeometry); + }; + teardown.push(() => { + detachDomGeometryListeners(); + cancelGeometryFrame(); + geometryListeners.clear(); + pendingGeometryReasons.clear(); + }); + // Wire SuperDoc-instance events. The wrapper-side bus (editorCreate / // document-mode-change / zoomChange) is the only path for some of // these signals today; if the wrapper migrates them to the editor @@ -966,8 +1042,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { SUPERDOC_EVENTS.forEach((name) => { superdoc.on?.(name, scheduleNotify); }); + // zoom drives geometry (post-paint, tagged via onGeometryLayout) — separate + // from the slice recompute that SUPERDOC_EVENTS triggers. + superdoc.on?.('zoomChange', onGeometryZoom); teardown.push(() => { SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify)); + superdoc.off?.('zoomChange', onGeometryZoom); }); } @@ -1089,8 +1169,18 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { PRESENTATION_EVENTS.forEach((name) => { next.on?.(name, onPresentationChange); }); + // Geometry-only: layout repaints move painted rects without a body + // `transaction`. Drive the viewport geometry signal, NOT the slice + // recompute (which would re-attach editor listeners on every repaint). + // Listen to `layoutUpdated` only: `paginationUpdate` is emitted + // back-to-back with the same payload for the same paint + // (PresentationEditor.ts:6491-6492), so subscribing to both would + // double-count one repaint — a zoom would coalesce to 'mixed' instead of + // 'zoom'. `layoutUpdated` alone covers every repaint. + next.on?.('layoutUpdated', onGeometryLayout); currentPresentationTeardown = () => { PRESENTATION_EVENTS.forEach((name) => next.off?.(name, onPresentationChange)); + next.off?.('layoutUpdated', onGeometryLayout); }; }; @@ -1927,6 +2017,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }, + observe(listener: (event: ViewportGeometryEvent) => void): () => void { + geometryListeners.add(listener); + // Attach the DOM scroll/resize listeners only while someone is observing. + if (geometryListeners.size === 1) attachDomGeometryListeners(); + return () => { + if (!geometryListeners.delete(listener)) return; + if (geometryListeners.size === 0) { + detachDomGeometryListeners(); + cancelGeometryFrame(); + pendingGeometryReasons.clear(); + zoomPending = false; + } + }; + }, + async scrollIntoView(input: ScrollIntoViewInput): Promise { return runScrollIntoView(input); }, diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index a5c394a892..b1bac10f5e 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -714,11 +714,10 @@ export interface SuperDocUI { selection: SelectionHandle; /** - * Viewport domain — imperative geometry queries for sticky-card / - * floating-toolbar placement against painted entities and ranges. - * No subscription substrate — viewport rects are read on-demand by - * the consumer (e.g. on hover, on scroll, on layout-change events - * the consumer already listens to). Browser-only by definition. + * Viewport domain — geometry queries for sticky-card / floating-toolbar + * placement against painted entities and ranges, plus + * {@link ViewportHandle.observe} to learn when those rects may have moved. + * Browser-only by definition. */ viewport: ViewportHandle; @@ -1831,17 +1830,38 @@ export type ViewportRectResult = }; /** - * Imperative viewport-geometry surface. No subscription primitive — - * rects are read on demand. Consumers who need to reflow on layout - * change typically already listen to a `transaction` / `paint` / - * `scroll` event upstream and call `getRect` from there. + * Reason a {@link ViewportHandle.observe} notification fired. `'mixed'` + * when more than one change coalesced into the same animation frame. */ +export type ViewportGeometryReason = 'layout' | 'zoom' | 'scroll' | 'resize' | 'mixed'; + +/** + * Payload for {@link ViewportHandle.observe}. Intentionally minimal: the + * signal means "your cached `getRect()` coordinates may be stale, re-query" - + * it carries no geometry. + */ +export interface ViewportGeometryEvent { + reason: ViewportGeometryReason; +} + export interface ViewportHandle { /** * Look up the painted rectangle(s) of an entity or text range in * viewport coordinates. Synchronous — no DOM mutation required. */ getRect(input: ViewportGetRectInput): ViewportRectResult; + /** + * Subscribe to viewport geometry invalidation. The listener fires (once + * per animation frame, coalesced) after anything that can move painted + * rectangles: layout / pagination repaints, zoom, and DOM scroll / resize. + * It carries no coordinates — re-query {@link getRect} for the entities you + * care about. Returns an unsubscribe. + * + * This is the single signal overlays should listen to instead of + * hand-wiring scroll + resize + layout + zoom (and still missing cases like + * reflow and zoom, which fire no scroll event). + */ + observe(listener: (event: ViewportGeometryEvent) => void): () => void; /** * Scroll the viewport so the target is visible. Browser-only by * definition: drives `presentation.navigateTo()` for entity targets diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index e3706880c3..5ec9ee543b 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -555,3 +555,137 @@ describe('ui.viewport.contextAt - bundle composition', () => { ui.destroy(); }); }); + +describe('ui.viewport.observe — geometry invalidation (SD-3311)', () => { + // The rAF flush resolves within a frame; a short real-timer wait covers both + // the requestAnimationFrame path and the setTimeout fallback. + const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 30)); + + it('fires once per frame on scroll (reason "scroll") and stops after unsubscribe', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + const unsubscribe = ui.viewport.observe((e) => events.push(e)); + + // Burst in one frame -> a single coalesced notification. + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + await nextFrame(); + expect(events).toEqual([{ reason: 'scroll' }]); + + unsubscribe(); + window.dispatchEvent(new Event('scroll')); + await nextFrame(); + expect(events).toHaveLength(1); // no notification after unsubscribe + + ui.destroy(); + }); + + it('coalesces different reasons in the same frame to "mixed"', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + await nextFrame(); + expect(events).toEqual([{ reason: 'mixed' }]); + + ui.destroy(); + }); + + it('does not notify after the UI is destroyed', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + ui.destroy(); + window.dispatchEvent(new Event('resize')); + await nextFrame(); + expect(events).toEqual([]); + }); +}); + +// Stub with real event emitters so tests can drive the engine signals that +// feed ui.viewport.observe: superdoc `zoomChange` and presentation +// `layoutUpdated` / `paginationUpdate`. +function makeEmitter() { + const map = new Map void>>(); + return { + on: (e: string, h: (p?: unknown) => void) => { + if (!map.has(e)) map.set(e, new Set()); + map.get(e)!.add(h); + }, + off: (e: string, h: (p?: unknown) => void) => { + map.get(e)?.delete(h); + }, + emit: (e: string, p?: unknown) => [...(map.get(e) ?? [])].forEach((h) => h(p)), + }; +} + +function makeGeometryStub() { + const sd = makeEmitter(); + const pres = makeEmitter(); + const emptyList = () => ({ evaluatedRevision: 'r1', total: 0, items: [], page: { limit: 0, offset: 0, returned: 0 } }); + const editor: { on: ReturnType; off: ReturnType; doc: unknown; presentationEditor: unknown } = { + on: vi.fn(), + off: vi.fn(), + doc: { + selection: { current: vi.fn(() => ({ empty: true })) }, + comments: { list: vi.fn(emptyList) }, + trackChanges: { list: vi.fn(emptyList) }, + contentControls: { list: vi.fn(() => ({ items: [], total: 0 })) }, + }, + presentationEditor: undefined, + }; + editor.presentationEditor = { + on: pres.on, + off: pres.off, + getActiveEditor: () => editor, + getEntityRects: vi.fn(() => []), + navigateTo: vi.fn(async () => true), + }; + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: sd.on as never, + off: sd.off as never, + }; + return { superdoc, emitSuperdoc: sd.emit, emitPresentation: pres.emit }; +} + +describe('ui.viewport.observe — repaint reason (SD-3311 regression)', () => { + const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 30)); + + it('reports "zoom" for a zoom repaint, not "mixed" (layoutUpdated + paginationUpdate are one paint)', async () => { + const { superdoc, emitSuperdoc, emitPresentation } = makeGeometryStub(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + // zoomChange (pre-paint), then the paired post-paint repaint events. + emitSuperdoc('zoomChange'); + emitPresentation('layoutUpdated'); + emitPresentation('paginationUpdate'); // same paint / payload — must not double-count + await nextFrame(); + + expect(events).toEqual([{ reason: 'zoom' }]); + ui.destroy(); + }); + + it('reports "layout" for a plain repaint (the paginationUpdate alias does not make it "mixed")', async () => { + const { superdoc, emitPresentation } = makeGeometryStub(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + emitPresentation('layoutUpdated'); + emitPresentation('paginationUpdate'); + await nextFrame(); + + expect(events).toEqual([{ reason: 'layout' }]); + ui.destroy(); + }); +});