Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept iframe-owned scroll elements

When SuperDoc is mounted in a different browsing context, such as an iframe, the resolved scroll container is an element from that iframe's realm, so instanceof HTMLElement against the parent/global constructor is false. In that case ui.viewport.getScrollContainer() returns null, causing overlay consumers to fall back to the wrong window and miss scroll events/measurements even though an element scroller exists. Use the container's ownerDocument.defaultView?.HTMLElement (or a structural element check) so cross-realm HTML elements are returned.

Useful? React with 👍 / 👎.

}

/**
* Selection overlay element used for caret + highlight rendering.
*
Expand Down
13 changes: 13 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 @@ -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;
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions packages/super-editor/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ export interface Subscribable<T> {
* 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 63 additions & 2 deletions packages/super-editor/src/ui/viewport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<typeof vi.fn>; off: ReturnType<typeof vi.fn>; doc: unknown; presentationEditor: unknown } = {
const emptyList = () => ({
evaluatedRevision: 'r1',
total: 0,
items: [],
page: { limit: 0, offset: 0, returned: 0 },
});
const editor: {
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
doc: unknown;
presentationEditor: unknown;
} = {
on: vi.fn(),
off: vi.fn(),
doc: {
Expand Down Expand Up @@ -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();
});
});
Loading