Skip to content

Commit 18a0d19

Browse files
committed
feat(super-editor): add ui.viewport.getScrollContainer (SD-3324)
Exposes the editor's resolved scroll container — the scrollable ancestor of the painted host (occasionally the host itself) — via a public `scrollContainer` getter on PresentationEditor and `ui.viewport.getScrollContainer()`. Overlay consumers need the element SuperDoc actually scrolls to attach scroll listeners and measure against; getHost() returns the painted host, which is usually not the scroller. Returns HTMLElement | null, with null meaning the document/window scrolls (fall back to window). Also corrects the visibleHost JSDoc that wrongly described it as the scroll container.
1 parent 7728b1f commit 18a0d19

4 files changed

Lines changed: 92 additions & 6 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,22 +1738,37 @@ export class PresentationEditor extends EventEmitter {
17381738
/**
17391739
* Alias for the visible host container so callers can attach listeners explicitly.
17401740
*
1741-
* This is the main scrollable container that hosts the rendered pages.
1742-
* Use this element to attach scroll listeners, measure viewport bounds, or
1743-
* position floating UI elements relative to the editor.
1741+
* The painted host element that contains the rendered pages. This is
1742+
* NOT necessarily the scroll container — the scrollable element is
1743+
* often an ancestor. Use {@link scrollContainer} to attach scroll
1744+
* listeners or measure the scroll viewport; use the host to position
1745+
* floating UI relative to the painted content.
17441746
*
17451747
* @returns The visible host HTMLElement
17461748
*
17471749
* @example
17481750
* ```typescript
17491751
* const host = presentation.visibleHost;
1750-
* host.addEventListener('scroll', () => console.log('Scrolled!'));
1752+
* const rect = host.getBoundingClientRect();
17511753
* ```
17521754
*/
17531755
get visibleHost(): HTMLElement {
17541756
return this.#visibleHost;
17551757
}
17561758

1759+
/**
1760+
* The resolved scroll container: the nearest ancestor of the visible
1761+
* host with `overflow: auto`/`scroll` (it may be the host itself). It
1762+
* can change after the first layout if a closer scrollable ancestor is
1763+
* detected. Returns `null` when the document/window scrolls instead of
1764+
* a dedicated element — callers should fall back to `window` then.
1765+
*
1766+
* @returns The scroll container element, or `null` when the window scrolls
1767+
*/
1768+
get scrollContainer(): HTMLElement | null {
1769+
return this.#scrollContainer instanceof HTMLElement ? this.#scrollContainer : null;
1770+
}
1771+
17571772
/**
17581773
* Selection overlay element used for caret + highlight rendering.
17591774
*

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
20712071
return editor?.presentationEditor?.visibleHost ?? null;
20722072
},
20732073

2074+
getScrollContainer(): HTMLElement | null {
2075+
const editor = resolveHostEditor(superdoc);
2076+
return editor?.presentationEditor?.scrollContainer ?? null;
2077+
},
2078+
20742079
positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null {
20752080
if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return null;
20762081
const hostEditor = resolveHostEditor(superdoc);

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ export interface SuperDocEditorLike {
219219
* from the wrong instance.
220220
*/
221221
visibleHost?: HTMLElement;
222+
/**
223+
* Resolved scroll container (the scrollable ancestor of the host, or
224+
* the host itself). Consumed by `ui.viewport.getScrollContainer`.
225+
* `null` when the document/window scrolls instead of an element.
226+
*/
227+
scrollContainer?: HTMLElement | null;
222228
/**
223229
* Coordinate-to-position helper. Consumed by
224230
* `ui.viewport.positionAt` to resolve a viewport `(x, y)` to a
@@ -1954,6 +1960,20 @@ export interface ViewportHandle {
19541960
* which scope correctly across painted-DOM and hidden-DOM events.
19551961
*/
19561962
getHost(): HTMLElement | null;
1963+
/**
1964+
* The element SuperDoc actually scrolls — the scrollable ancestor of
1965+
* the painted host (occasionally the host itself), resolved by walking
1966+
* up for `overflow: auto`/`scroll`. This is what overlay consumers
1967+
* attach scroll listeners to and measure against; {@link getHost} is
1968+
* the painted host and is often NOT the scroller.
1969+
*
1970+
* Returns `null` when no editor is mounted, or when the document /
1971+
* window scrolls rather than a dedicated element — fall back to
1972+
* `window` in that case. The scroller can change after the first
1973+
* layout, so read it when you need it rather than caching across
1974+
* layout changes (pair with {@link observe}).
1975+
*/
1976+
getScrollContainer(): HTMLElement | null;
19571977
/**
19581978
* Resolve a viewport coordinate to a position in the editor's
19591979
* document, or `null` when the point is outside the painted host or

packages/super-editor/src/ui/viewport.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,42 @@ describe('ui.viewport.getHost', () => {
388388
});
389389
});
390390

391+
describe('ui.viewport.getScrollContainer', () => {
392+
it('returns the resolved scroll container when one is mounted', () => {
393+
const { superdoc } = makeStubs();
394+
const scroller = document.createElement('div');
395+
document.body.appendChild(scroller);
396+
(
397+
superdoc.activeEditor as unknown as { presentationEditor: { scrollContainer: HTMLElement } }
398+
).presentationEditor.scrollContainer = scroller;
399+
400+
const ui = createSuperDocUI({ superdoc });
401+
// Distinct from getHost(): the scroller is not the painted host.
402+
expect(ui.viewport.getScrollContainer()).toBe(scroller);
403+
404+
scroller.remove();
405+
ui.destroy();
406+
});
407+
408+
it('returns null when the document/window scrolls (no element scroller)', () => {
409+
const { superdoc } = makeStubs();
410+
(
411+
superdoc.activeEditor as unknown as { presentationEditor: { scrollContainer: HTMLElement | null } }
412+
).presentationEditor.scrollContainer = null;
413+
const ui = createSuperDocUI({ superdoc });
414+
expect(ui.viewport.getScrollContainer()).toBeNull();
415+
ui.destroy();
416+
});
417+
418+
it('returns null when no editor is mounted', () => {
419+
const { superdoc } = makeStubs();
420+
(superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined;
421+
const ui = createSuperDocUI({ superdoc });
422+
expect(ui.viewport.getScrollContainer()).toBeNull();
423+
ui.destroy();
424+
});
425+
});
426+
391427
describe('ui.viewport.positionAt — input validation', () => {
392428
it('returns null for invalid input (missing or non-numeric coordinates)', () => {
393429
const { superdoc } = makeStubs();
@@ -628,8 +664,18 @@ function makeEmitter() {
628664
function makeGeometryStub() {
629665
const sd = makeEmitter();
630666
const pres = makeEmitter();
631-
const emptyList = () => ({ evaluatedRevision: 'r1', total: 0, items: [], page: { limit: 0, offset: 0, returned: 0 } });
632-
const editor: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn>; doc: unknown; presentationEditor: unknown } = {
667+
const emptyList = () => ({
668+
evaluatedRevision: 'r1',
669+
total: 0,
670+
items: [],
671+
page: { limit: 0, offset: 0, returned: 0 },
672+
});
673+
const editor: {
674+
on: ReturnType<typeof vi.fn>;
675+
off: ReturnType<typeof vi.fn>;
676+
doc: unknown;
677+
presentationEditor: unknown;
678+
} = {
633679
on: vi.fn(),
634680
off: vi.fn(),
635681
doc: {

0 commit comments

Comments
 (0)