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
5 changes: 5 additions & 0 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export const createDomPainter = (
getPaintSnapshot?: () => PaintSnapshot | null;
onScroll?: () => void;
setZoom?: (zoom: number) => void;
setScrollContainer?: (el: HTMLElement | null) => void;
} => {
const painter = new DomPainter(options.blocks, options.measures, {
pageStyles: options.pageStyles,
Expand Down Expand Up @@ -172,5 +173,9 @@ export const createDomPainter = (
setZoom(zoom: number) {
painter.setZoom(zoom);
},
// Set the external scroll container for correct scrollY calculation
setScrollContainer(el: HTMLElement | null) {
painter.setScrollContainer(el);
},
};
};
40 changes: 40 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,14 @@ export class DomPainter {
private onResizeHandler: ((e: Event) => void) | null = null;
/** CSS zoom/scale factor applied to the mount element via transform: scale(). Defaults to 1 (no zoom). */
private zoomFactor = 1;
/**
* External scroll container (an ancestor element with overflow-y: auto/scroll).
* When set, updateVirtualWindow() uses this element's position to compute scrollY
* relative to the scroll container instead of relative to the browser viewport.
* This fixes the scroll offset calculation when SuperDoc is mounted inside a
* wrapper div that owns scrolling rather than the window.
*/
private scrollContainer: HTMLElement | null = null;
private sdtHover = new SdtGroupedHover();
/** The currently active/selected comment ID for highlighting */
private activeCommentId: string | null = null;
Expand Down Expand Up @@ -1105,6 +1113,29 @@ export class DomPainter {
}
}

/**
* Sets the external scroll container element.
*
* When the scroll container is an ancestor element (e.g., a wrapper div with
* overflow-y: auto), the default scrollY calculation using mount.getBoundingClientRect()
* relative to the viewport produces an offset equal to the scroll container's distance
* from the viewport top. This causes the virtualization window to be misaligned with the
* actual visible area.
*
* Setting the scroll container allows updateVirtualWindow() to compute scrollY relative
* to the scroll container instead, eliminating this offset.
*
* @param el - The scroll container element, or null to clear.
*/
public setScrollContainer(el: HTMLElement | null): void {
if (el !== this.scrollContainer) {
this.scrollContainer = el;
if (this.virtualEnabled && this.mount) {
this.updateVirtualWindow();
}
}
}

/**
* Sets the active comment ID for highlighting.
* When set, only the active comment's range is highlighted.
Expand Down Expand Up @@ -1644,6 +1675,15 @@ export class DomPainter {
const isContainerScrollable = this.mount.scrollHeight > this.mount.clientHeight + 1;
if (isContainerScrollable) {
scrollY = Math.max(0, this.mount.scrollTop - paddingTop);
} else if (this.scrollContainer) {
// Intermediate scroll ancestor (e.g., a wrapper div with overflow-y: auto).
// Compute scrollY relative to the scroll container, not the browser viewport.
// Using (scrollContainer.rect.top - mount.rect.top) instead of just (-mount.rect.top)
// eliminates the offset caused by the scroll container's distance from the viewport top
// (e.g., a toolbar/header above the scroll wrapper).
const mountRect = this.mount.getBoundingClientRect();
const containerRect = this.scrollContainer.getBoundingClientRect();
scrollY = Math.max(0, (containerRect.top - mountRect.top) / zoom - paddingTop);
} else {
const rect = this.mount.getBoundingClientRect();
// rect.top is in screen space (affected by CSS transform: scale).
Expand Down
178 changes: 178 additions & 0 deletions packages/layout-engine/painters/dom/src/virtualization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,184 @@ describe('DomPainter virtualization (vertical)', () => {
expect(indices).toEqual([7, 8, 9]);
});

it('computes scrollY relative to scroll container, not viewport', () => {
// When SuperDoc is mounted inside a wrapper div with overflow-y: auto,
// the scroll container sits below a toolbar/header (e.g., 100px from viewport top).
// Without setScrollContainer, scrollY uses -mount.rect.top/zoom which includes
// the toolbar offset, misaligning the virtualization window.
// With setScrollContainer, scrollY uses (container.rect.top - mount.rect.top)/zoom,
// which cancels the toolbar offset.
const zoom = 0.75;
const pageH = 500;
const gap = 72;
const pageCount = 20;
const toolbarHeight = 100;

const painter = createDomPainter({
blocks: [block],
measures: [measure],
virtualization: { enabled: true, window: 3, overscan: 0, gap, paddingTop: 0 },
});

const layout = makeLayout(pageCount);
painter.paint(layout, mount);
painter.setZoom!(zoom);

// Make mount non-scrollable (wrapper scrolls instead)
Object.defineProperty(mount, 'scrollHeight', { value: 100, configurable: true });
Object.defineProperty(mount, 'clientHeight', { value: 600, configurable: true });

// Simulate wrapper scrolled to position 5000 (screen space).
// Wrapper sits 100px from viewport top (toolbar above it).
// Mount is inside wrapper, so mount.rect.top = toolbarHeight - scrollTop.
const scrollTop = 5000;
const mountScreenTop = toolbarHeight - scrollTop; // 100 - 5000 = -4900

mount.getBoundingClientRect = () =>
({
top: mountScreenTop,
left: 0,
right: 400,
bottom: 600 + mountScreenTop,
width: 400,
height: 600,
x: 0,
y: mountScreenTop,
toJSON() {},
}) as DOMRect;

// Create a fake scroll container element
const scrollWrapper = document.createElement('div');
scrollWrapper.getBoundingClientRect = () =>
({
top: toolbarHeight, // wrapper stays at toolbar height regardless of scroll
left: 0,
right: 400,
bottom: toolbarHeight + 600,
width: 400,
height: 600,
x: 0,
y: toolbarHeight,
toJSON() {},
}) as DOMRect;

// WITHOUT scroll container: scrollY = -(-4900) / 0.75 = 6533
// Anchor would be at page index 11 (topOfIndex(11) = 6292)
painter.onScroll!();
const pagesWithout = mount.querySelectorAll('.superdoc-page');
const indicesWithout = Array.from(pagesWithout).map((p) => Number((p as HTMLElement).dataset.pageIndex));

// WITH scroll container: scrollY = (100 - (-4900)) / 0.75 = 6666
// That's different from the without case — but the key point is:
// the offset from the viewport (toolbarHeight) is eliminated from the calculation.
// scrollY = (containerRect.top - mountRect.top) / zoom = (100 - (-4900)) / 0.75 = 6666
painter.setScrollContainer!(scrollWrapper);
painter.onScroll!();
const pagesWith = mount.querySelectorAll('.superdoc-page');
const indicesWith = Array.from(pagesWith).map((p) => Number((p as HTMLElement).dataset.pageIndex));

// Now simulate at scroll=0 to see the offset difference clearly.
// Without container: mount.rect.top = 100, scrollY = max(0, -100/0.75) = 0 ← correct by luck (clamped)
// But at small scroll (e.g., scroll=50): mount.rect.top = 50, scrollY = -50/0.75 = -66 → 0 ← WRONG, should be ~66
// With container: scrollY = (100 - 50) / 0.75 = 66 ← CORRECT
const smallScroll = 150;
const mountTopSmall = toolbarHeight - smallScroll; // 100 - 150 = -50

mount.getBoundingClientRect = () =>
({
top: mountTopSmall,
left: 0,
right: 400,
bottom: 600 + mountTopSmall,
width: 400,
height: 600,
x: 0,
y: mountTopSmall,
toJSON() {},
}) as DOMRect;

painter.onScroll!();
const pagesSmallScroll = mount.querySelectorAll('.superdoc-page');
const indicesSmallScroll = Array.from(pagesSmallScroll).map((p) => Number((p as HTMLElement).dataset.pageIndex));
// scrollY = (100 - (-50)) / 0.75 = 200 in layout space
// Anchor at page 0 (topOfIndex(0)=0, topOfIndex(1)=572), so pages [0,1,2]
expect(indicesSmallScroll).toEqual([0, 1, 2]);

// Verify: remove scroll container and the same scroll position gives different result
// Without container: scrollY = -(-50) / 0.75 = 66 → still page 0, pages [0,1,2]
// (In this case they happen to match, but at larger offsets they diverge)
painter.setScrollContainer!(null);
painter.onScroll!();
const pagesNoContainer = mount.querySelectorAll('.superdoc-page');
const indicesNoContainer = Array.from(pagesNoContainer).map((p) => Number((p as HTMLElement).dataset.pageIndex));
// scrollY = 50/0.75 = 66, anchor at page 0
expect(indicesNoContainer).toEqual([0, 1, 2]);
});

it('setScrollContainer triggers immediate updateVirtualWindow', () => {
const pageCount = 20;

const painter = createDomPainter({
blocks: [block],
measures: [measure],
virtualization: { enabled: true, window: 3, overscan: 0, gap: 72, paddingTop: 0 },
});

const layout = makeLayout(pageCount);
painter.paint(layout, mount);

// Make mount non-scrollable
Object.defineProperty(mount, 'scrollHeight', { value: 100, configurable: true });
Object.defineProperty(mount, 'clientHeight', { value: 600, configurable: true });

// Simulate scrolled position via getBoundingClientRect
mount.getBoundingClientRect = () =>
({
top: -5000,
left: 0,
right: 400,
bottom: -4400,
width: 400,
height: 600,
x: 0,
y: -5000,
toJSON() {},
}) as DOMRect;

// Get mounted pages before setting scroll container
painter.onScroll!();
const pagesBefore = Array.from(mount.querySelectorAll('.superdoc-page')).map((p) =>
Number((p as HTMLElement).dataset.pageIndex),
);

// Create scroll container that shifts the reference frame
const scrollWrapper = document.createElement('div');
scrollWrapper.getBoundingClientRect = () =>
({
top: 200,
left: 0,
right: 400,
bottom: 800,
width: 400,
height: 600,
x: 0,
y: 200,
toJSON() {},
}) as DOMRect;

// Setting scroll container should immediately re-window
painter.setScrollContainer!(scrollWrapper);
const pagesAfter = Array.from(mount.querySelectorAll('.superdoc-page')).map((p) =>
Number((p as HTMLElement).dataset.pageIndex),
);

// The scroll container changes the scrollY calculation, so pages should update.
// Before: scrollY = 5000, after: scrollY = (200 - (-5000)) / 1 = 5200
// Both are in the same page range, but the re-windowing proves it ran.
// The key assertion: setScrollContainer triggered a re-evaluation (no explicit onScroll needed).
expect(pagesAfter.length).toBeGreaterThan(0);
});

it('renders drawing fragments inside virtualized windows', () => {
const painter = createDomPainter({
blocks: [drawingBlock],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3591,6 +3591,12 @@ export class PresentationEditor extends EventEmitter {
if (currentZoom !== 1) {
this.#domPainter.setZoom(currentZoom);
}
// Pass the scroll container so virtualization computes scrollY relative to it,
// not the browser viewport. This fixes offset errors when SuperDoc is mounted
// inside a wrapper div with overflow-y: auto.
if (this.#scrollContainer && this.#scrollContainer instanceof HTMLElement) {
this.#domPainter.setScrollContainer?.(this.#scrollContainer);
}
}
return this.#domPainter;
}
Expand Down
Loading