diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 60dfe2a0b3..0827031589 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -1738,22 +1738,37 @@ export class PresentationEditor extends EventEmitter { /** * Alias for the visible host container so callers can attach listeners explicitly. * - * This is the main scrollable container that hosts the rendered pages. - * Use this element to attach scroll listeners, measure viewport bounds, or - * position floating UI elements relative to the editor. + * The painted host element that contains the rendered pages. This is + * NOT necessarily the scroll container — the scrollable element is + * often an ancestor. Use {@link scrollContainer} to attach scroll + * listeners or measure the scroll viewport; use the host to position + * floating UI relative to the painted content. * * @returns The visible host HTMLElement * * @example * ```typescript * const host = presentation.visibleHost; - * host.addEventListener('scroll', () => console.log('Scrolled!')); + * const rect = host.getBoundingClientRect(); * ``` */ get visibleHost(): HTMLElement { return this.#visibleHost; } + /** + * The resolved scroll container: the nearest ancestor of the visible + * host with `overflow: auto`/`scroll` (it may be the host itself). It + * can change after the first layout if a closer scrollable ancestor is + * detected. Returns `null` when the document/window scrolls instead of + * a dedicated element — callers should fall back to `window` then. + * + * @returns The scroll container element, or `null` when the window scrolls + */ + get scrollContainer(): HTMLElement | null { + return this.#scrollContainer instanceof HTMLElement ? this.#scrollContainer : null; + } + /** * Selection overlay element used for caret + highlight rendering. * 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 3d61f0b670..b45c8d907d 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1013,6 +1013,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; const onWindowScrollGeometry = () => scheduleGeometry('scroll'); const onWindowResizeGeometry = () => scheduleGeometry('resize'); + // The comments rail toggling shifts/reflows document geometry but does + // not reliably emit a layout repaint on its own, so cached rects would + // silently go stale. Bridge the explicit sidebar-toggle signal into a + // geometry invalidation. Reuses the 'layout' reason — consumers only + // re-query on it, so no new public reason is warranted. + const onGeometrySidebar = () => scheduleGeometry('layout'); let domGeometryAttached = false; const attachDomGeometryListeners = () => { if (domGeometryAttached || typeof window === 'undefined') return; @@ -1046,9 +1052,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // zoom drives geometry (post-paint, tagged via onGeometryLayout) — separate // from the slice recompute that SUPERDOC_EVENTS triggers. superdoc.on?.('zoomChange', onGeometryZoom); + superdoc.on?.('sidebar-toggle', onGeometrySidebar); teardown.push(() => { SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify)); superdoc.off?.('zoomChange', onGeometryZoom); + superdoc.off?.('sidebar-toggle', onGeometrySidebar); }); } @@ -2071,6 +2079,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return editor?.presentationEditor?.visibleHost ?? null; }, + getScrollContainer(): HTMLElement | null { + const editor = resolveHostEditor(superdoc); + return editor?.presentationEditor?.scrollContainer ?? null; + }, + positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null { if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return null; const hostEditor = resolveHostEditor(superdoc); diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 3517136ce2..d5f652f311 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -39,9 +39,11 @@ export interface Subscribable { * a SuperDoc-like host. Narrower than * `HeadlessToolbarSuperdocHostEvent` (which adds * `formatting-marks-change`); a custom UI host stub only has to - * support the three events the UI controller actually consumes. + * support the events the UI controller actually consumes. + * `sidebar-toggle` feeds the `ui.viewport.observe` geometry signal + * (the comments rail shifting layout). */ -export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange'; +export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'sidebar-toggle'; /** * Structural typing for the SuperDoc instance. Keeps the UI controller @@ -219,6 +221,12 @@ export interface SuperDocEditorLike { * from the wrong instance. */ visibleHost?: HTMLElement; + /** + * Resolved scroll container (the scrollable ancestor of the host, or + * the host itself). Consumed by `ui.viewport.getScrollContainer`. + * `null` when the document/window scrolls instead of an element. + */ + scrollContainer?: HTMLElement | null; /** * Coordinate-to-position helper. Consumed by * `ui.viewport.positionAt` to resolve a viewport `(x, y)` to a @@ -1954,6 +1962,20 @@ export interface ViewportHandle { * which scope correctly across painted-DOM and hidden-DOM events. */ getHost(): HTMLElement | null; + /** + * The element SuperDoc actually scrolls — the scrollable ancestor of + * the painted host (occasionally the host itself), resolved by walking + * up for `overflow: auto`/`scroll`. This is what overlay consumers + * attach scroll listeners to and measure against; {@link getHost} is + * the painted host and is often NOT the scroller. + * + * Returns `null` when no editor is mounted, or when the document / + * window scrolls rather than a dedicated element — fall back to + * `window` in that case. The scroller can change after the first + * layout, so read it when you need it rather than caching across + * layout changes (pair with {@link observe}). + */ + getScrollContainer(): HTMLElement | null; /** * Resolve a viewport coordinate to a position in the editor's * document, or `null` when the point is outside the painted host or diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 5ec9ee543b..87c3ed3517 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -388,6 +388,42 @@ describe('ui.viewport.getHost', () => { }); }); +describe('ui.viewport.getScrollContainer', () => { + it('returns the resolved scroll container when one is mounted', () => { + const { superdoc } = makeStubs(); + const scroller = document.createElement('div'); + document.body.appendChild(scroller); + ( + superdoc.activeEditor as unknown as { presentationEditor: { scrollContainer: HTMLElement } } + ).presentationEditor.scrollContainer = scroller; + + const ui = createSuperDocUI({ superdoc }); + // Distinct from getHost(): the scroller is not the painted host. + expect(ui.viewport.getScrollContainer()).toBe(scroller); + + scroller.remove(); + ui.destroy(); + }); + + it('returns null when the document/window scrolls (no element scroller)', () => { + const { superdoc } = makeStubs(); + ( + superdoc.activeEditor as unknown as { presentationEditor: { scrollContainer: HTMLElement | null } } + ).presentationEditor.scrollContainer = null; + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getScrollContainer()).toBeNull(); + ui.destroy(); + }); + + it('returns null when no editor is mounted', () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getScrollContainer()).toBeNull(); + ui.destroy(); + }); +}); + describe('ui.viewport.positionAt — input validation', () => { it('returns null for invalid input (missing or non-numeric coordinates)', () => { const { superdoc } = makeStubs(); @@ -628,8 +664,18 @@ function makeEmitter() { 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 } = { + 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: { @@ -688,4 +734,19 @@ describe('ui.viewport.observe — repaint reason (SD-3311 regression)', () => { expect(events).toEqual([{ reason: 'layout' }]); ui.destroy(); }); + + it('fires a geometry invalidation on sidebar-toggle (reason "layout")', async () => { + // The comments rail toggling shifts geometry without a guaranteed + // layout repaint; observe must still notify so cached rects re-query. + const { superdoc, emitSuperdoc } = makeGeometryStub(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + emitSuperdoc('sidebar-toggle', true); + await nextFrame(); + + expect(events).toEqual([{ reason: 'layout' }]); + ui.destroy(); + }); });