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
4 changes: 3 additions & 1 deletion apps/docs/editor/custom-ui/content-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,18 @@ 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.*` |

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

`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
Expand Down
73 changes: 73 additions & 0 deletions demos/__tests__/contract-templates-chip-anchor.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.sd-field-chip');
const ctrl = activeId ? document.querySelector<HTMLElement>(`[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');
});
12 changes: 7 additions & 5 deletions demos/contract-templates/src/field-chip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
};
}
105 changes: 105 additions & 0 deletions packages/super-editor/src/ui/create-super-doc-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import type {
ViewportPositionAtInput,
ViewportPositionHit,
ViewportHandle,
ViewportGeometryEvent,
ViewportRect,
ViewportRectResult,
} from './types.js';
Expand Down Expand Up @@ -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<Exclude<ViewportGeometryEvent['reason'], 'mixed'>>();
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<typeof setTimeout>);
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<ViewportGeometryEvent['reason'], 'mixed'>) => {
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
Expand All @@ -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);
});
}

Expand Down Expand Up @@ -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);
};
};

Expand Down Expand Up @@ -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<ScrollIntoViewOutput> {
return runScrollIntoView(input);
},
Expand Down
38 changes: 29 additions & 9 deletions packages/super-editor/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading