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 054c595757..3c2a54b45e 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 @@ -1934,22 +1934,40 @@ 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 { + const container = this.#scrollContainer; + if (!container || !('ownerDocument' in container)) return null; + const HTMLElementCtor = container.ownerDocument?.defaultView?.HTMLElement; + return HTMLElementCtor && container instanceof HTMLElementCtor ? (container as HTMLElement) : null; + } + /** * Selection overlay element used for caret + highlight rendering. * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 7535fc4c9c..16e69eaa88 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -1623,6 +1623,17 @@ describe('PresentationEditor', () => { document.body.appendChild(container); } }); + + it('returns null for scrollContainer when the window scrolls', () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-window-scroll-container', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + }); + + expect(editor.scrollContainer).toBeNull(); + }); }); describe('setDocumentMode', () => { 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 073b9ffcda..4b6c8925bf 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1154,6 +1154,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; @@ -1188,10 +1194,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // from the slice recompute that SUPERDOC_EVENTS triggers. superdoc.on?.('zoomChange', onGeometryZoom); superdoc.on?.('fonts-changed', refreshFontOptionsAndNotify); + superdoc.on?.('sidebar-toggle', onGeometrySidebar); teardown.push(() => { SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify)); superdoc.off?.('zoomChange', onGeometryZoom); superdoc.off?.('fonts-changed', refreshFontOptionsAndNotify); + superdoc.off?.('sidebar-toggle', onGeometrySidebar); }); } @@ -2290,6 +2298,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 97deb610fa..25b40c4d4f 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -46,14 +46,17 @@ export interface Subscribable { * a SuperDoc-like host. Differs from `HeadlessToolbarSuperdocHostEvent` * (which adds `formatting-marks-change` but not `viewport-change`); a * custom UI host stub only has to support the events the UI - * controller actually consumes. + * controller actually consumes. `sidebar-toggle` feeds the + * `ui.viewport.observe` geometry signal when the comments rail shifts + * layout. */ export type SuperDocUIHostEvent = | 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change' - | 'fonts-changed'; + | 'fonts-changed' + | 'sidebar-toggle'; /** * Structural typing for the SuperDoc instance. Keeps the UI controller @@ -303,6 +306,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 @@ -2177,6 +2186,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 b138728815..5e05488382 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -583,6 +583,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(); @@ -893,4 +929,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(); + }); });