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 @@ -122,6 +122,7 @@ export const createDomPainter = (
getActiveComment?: () => string | null;
getPaintSnapshot?: () => PaintSnapshot | null;
onScroll?: () => void;
setZoom?: (zoom: number) => void;
} => {
const painter = new DomPainter(options.blocks, options.measures, {
pageStyles: options.pageStyles,
Expand Down Expand Up @@ -167,5 +168,9 @@ export const createDomPainter = (
onScroll() {
painter.onScroll();
},
// Notify painter of CSS transform scale so virtualization maps scroll correctly
setZoom(zoom: number) {
painter.setZoom(zoom);
},
};
};
33 changes: 30 additions & 3 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,8 @@ export class DomPainter {
private onScrollHandler: ((e: Event) => void) | null = null;
private onWindowScrollHandler: ((e: Event) => void) | null = null;
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;
private sdtHover = new SdtGroupedHover();
/** The currently active/selected comment ID for highlighting */
private activeCommentId: string | null = null;
Expand Down Expand Up @@ -1083,6 +1085,26 @@ export class DomPainter {
}
}

/**
* Sets the CSS zoom/scale factor applied to the mount element.
*
* When the mount element has `transform: scale(zoom)`, getBoundingClientRect()
* returns screen-space coordinates (scaled), but internal layout offsets are in
* unscaled layout space. This factor is used to convert between the two spaces
* during virtualization window calculations.
*
* @param zoom - The zoom/scale factor (e.g., 0.75 for 75% zoom). Defaults to 1.
*/
public setZoom(zoom: number): void {
const next = typeof zoom === 'number' && Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
if (next !== this.zoomFactor) {
this.zoomFactor = next;
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 @@ -1612,16 +1634,21 @@ export class DomPainter {
return;
}

// Map scrollTop -> anchor page index via prefix sums
// Map scrollTop -> anchor page index via prefix sums.
// virtualOffsets are in layout (unscaled) space, so scrollY must also be in layout space.
// When the mount has transform: scale(zoom), getBoundingClientRect() returns
// screen-space values that must be divided by zoom to get layout-space coordinates.
const paddingTop = this.getMountPaddingTopPx();
const zoom = this.zoomFactor;
let scrollY: number;
const isContainerScrollable = this.mount.scrollHeight > this.mount.clientHeight + 1;
if (isContainerScrollable) {
scrollY = Math.max(0, this.mount.scrollTop - paddingTop);
} else {
const rect = this.mount.getBoundingClientRect();
// Translate viewport scroll to content-space scroll offset
scrollY = Math.max(0, -rect.top - paddingTop);
// rect.top is in screen space (affected by CSS transform: scale).
// Divide by zoom to convert to layout space for comparison with virtualOffsets.
scrollY = Math.max(0, -rect.top / zoom - paddingTop);
}

// Binary search for anchor index such that topOfIndex(i) <= scrollY < topOfIndex(i+1)
Expand Down
51 changes: 51 additions & 0 deletions packages/layout-engine/painters/dom/src/virtualization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,57 @@ describe('DomPainter virtualization (vertical)', () => {
expect(footerEl).toBeTruthy();
});

it('corrects scroll position for zoom factor in non-scrollable container', () => {
// When the mount element has transform: scale(zoom), getBoundingClientRect() returns
// screen-space coordinates. The zoom factor divides rect.top to convert back to layout space.
// Without this correction, the virtual window drifts at non-100% zoom levels.
const zoom = 0.75;
const pageH = 500;
const gap = 72;
const pageCount = 20;

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);

// Simulate non-scrollable container: scrollHeight <= clientHeight so it uses getBoundingClientRect path
Object.defineProperty(mount, 'scrollHeight', { value: 100, configurable: true });
Object.defineProperty(mount, 'clientHeight', { value: 600, configurable: true });

// Simulate being scrolled to layout-space position ~5000px.
// In screen space (after zoom), rect.top = -5000 * zoom = -3750.
const layoutScrollY = 5000;
const screenTop = -layoutScrollY * zoom; // -3750
mount.getBoundingClientRect = () =>
({
top: screenTop,
left: 0,
right: 400,
bottom: 600 + screenTop,
width: 400,
height: 600,
x: 0,
y: screenTop,
toJSON() {},
}) as DOMRect;

painter.onScroll!();

// At layoutScrollY=5000, the anchor page is index 8 (topOfIndex(8)=4576 <= 5000, topOfIndex(9)=5148 > 5000).
// With window=3, overscan=0, the window is centered around the anchor: pages [7, 8, 9].
// Without the zoom correction, scrollY would be 3750 (screen-space), giving anchor=6 and pages [5, 6, 7].
const pages = mount.querySelectorAll('.superdoc-page');
const indices = Array.from(pages).map((p) => Number((p as HTMLElement).dataset.pageIndex));

expect(indices).toEqual([7, 8, 9]);
});

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 @@ -261,6 +261,8 @@ export class PresentationEditor extends EventEmitter {
}

#options: PresentationEditorOptions;
/** Key used to register this instance in the static registry. Separate from options.documentId to avoid mutating caller's object. */
#registryKey: string | null = null;
#editor: Editor;
#visibleHost: HTMLElement;
#viewportHost: HTMLElement;
Expand Down Expand Up @@ -610,10 +612,11 @@ export class PresentationEditor extends EventEmitter {
this.#setupInputBridge();
this.#syncTrackedChangesPreferences();

// Register this instance in the static registry
if (options.documentId) {
PresentationEditor.#instances.set(options.documentId, this);
}
// Register this instance in the static registry.
// Use a separate field to avoid mutating the caller's options object and to keep
// the registry key consistent with the overlay ID set earlier (line ~453).
this.#registryKey = options.documentId || `__anonymous_${Date.now()}_${Math.random().toString(36).slice(2)}`;
PresentationEditor.#instances.set(this.#registryKey, this);

this.#pendingDocChange = true;
this.#scheduleRerender();
Expand Down Expand Up @@ -2213,6 +2216,8 @@ export class PresentationEditor extends EventEmitter {
}
this.#layoutOptions.zoom = zoom;
this.#applyZoom();
// Notify DomPainter so virtualization accounts for the CSS transform scale
this.#domPainter?.setZoom?.(zoom);
this.emit('zoomChange', { zoom });
this.#scheduleSelectionUpdate();
// Trigger cursor updates on zoom changes
Expand Down Expand Up @@ -2298,8 +2303,9 @@ export class PresentationEditor extends EventEmitter {
}

// Unregister from static registry
if (this.#options?.documentId) {
PresentationEditor.#instances.delete(this.#options.documentId);
if (this.#registryKey) {
PresentationEditor.#instances.delete(this.#registryKey);
this.#registryKey = null;
}

// Clean up header/footer session manager
Expand Down Expand Up @@ -3392,17 +3398,30 @@ export class PresentationEditor extends EventEmitter {

#ensurePainter(blocks: FlowBlock[], measures: Measure[]) {
if (!this.#domPainter) {
// Ensure the virtualization gap matches the effective page gap so that
// DomPainter's spacer/offset math stays consistent with #applyZoom() height calculations.
const virtualization = this.#layoutOptions.virtualization;
const effectiveGap = this.#getEffectivePageGap();
const normalizedVirtualization = virtualization?.enabled
? { ...virtualization, gap: virtualization.gap ?? effectiveGap }
: virtualization;

this.#domPainter = createDomPainter({
blocks,
measures,
layoutMode: this.#layoutOptions.layoutMode ?? 'vertical',
virtualization: this.#layoutOptions.virtualization,
virtualization: normalizedVirtualization,
pageStyles: this.#layoutOptions.pageStyles,
headerProvider: this.#headerFooterSession?.headerDecorationProvider,
footerProvider: this.#headerFooterSession?.footerDecorationProvider,
ruler: this.#layoutOptions.ruler,
pageGap: this.#layoutState.layout?.pageGap ?? this.#getEffectivePageGap(),
pageGap: this.#layoutState.layout?.pageGap ?? effectiveGap,
});
// Pass the current zoom so virtualization accounts for the CSS transform scale
const currentZoom = this.#layoutOptions.zoom ?? 1;
if (currentZoom !== 1) {
this.#domPainter.setZoom(currentZoom);
}
}
return this.#domPainter;
}
Expand Down Expand Up @@ -4865,10 +4884,16 @@ export class PresentationEditor extends EventEmitter {
this.#viewportHost.style.width = `${scaledWidth}px`;
this.#viewportHost.style.minWidth = `${scaledWidth}px`;
this.#viewportHost.style.minHeight = `${scaledHeight}px`;
this.#viewportHost.style.height = '';
this.#viewportHost.style.overflow = '';
this.#viewportHost.style.transform = '';

this.#painterHost.style.width = `${totalWidth}px`;
this.#painterHost.style.minHeight = `${maxHeight}px`;
// Negative margin compensates for the CSS box overflow from transform: scale().
// At zoom < 1 the unscaled CSS box is larger than the visual; this pulls the
// bottom edge up to match, without clipping overlays (e.g., cursor labels).
this.#painterHost.style.marginBottom = zoom !== 1 ? `${maxHeight * zoom - maxHeight}px` : '';
this.#painterHost.style.transformOrigin = 'top left';
this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;

Expand All @@ -4887,19 +4912,30 @@ export class PresentationEditor extends EventEmitter {
//
// This ensures the scroll container sees the correct scaled content size while
// the transform provides visual scaling.
//
// CSS transform: scale() does NOT change the element's CSS box dimensions.
// At zoom < 1, painterHost's CSS box stays at the full unscaled height while its
// visual size is smaller. A negative margin-bottom on painterHost compensates for
// the difference, so the scroll container sees the correct scaled size without
// clipping overlays (e.g., collaboration cursor labels that extend above their caret).
const scaledWidth = maxWidth * zoom;
const scaledHeight = totalHeight * zoom;

// Set viewport to scaled dimensions for scroll container
this.#viewportHost.style.width = `${scaledWidth}px`;
this.#viewportHost.style.minWidth = `${scaledWidth}px`;
this.#viewportHost.style.minHeight = `${scaledHeight}px`;
this.#viewportHost.style.height = '';
this.#viewportHost.style.overflow = '';
this.#viewportHost.style.transform = '';

// Set painterHost to UNSCALED dimensions and apply transform
// This way: 816px * scale(1.5) = 1224px visual = matches viewport
// Set painterHost to UNSCALED dimensions and apply transform.
// Negative margin compensates for the CSS box overflow from transform: scale().
// At zoom < 1: totalHeight=74304 with scale(0.75) → visual 55728px but CSS box stays 74304px.
// marginBottom = totalHeight * zoom - totalHeight = 74304 * 0.75 - 74304 = -18576px
// This shrinks the layout contribution to match the visual size.
this.#painterHost.style.width = `${maxWidth}px`;
this.#painterHost.style.minHeight = `${totalHeight}px`;
this.#painterHost.style.marginBottom = zoom !== 1 ? `${totalHeight * zoom - totalHeight}px` : '';
this.#painterHost.style.transformOrigin = 'top left';
this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { test, expect } from '../../fixtures/superdoc.js';

test.use({ config: { toolbar: 'none' } });

test('content is visible at 75% zoom when scrolled to mid-document', async ({ superdoc }) => {
// Generate a long document (~60 paragraphs) to span many pages and trigger virtualization.
await superdoc.page.evaluate(() => {
const editor = (window as any).editor;
const { state } = editor;
const { schema } = state;

const paragraphs: any[] = [];
for (let i = 0; i < 60; i++) {
const text = schema.text(
`Paragraph number ${i + 1}. ` +
'This line contains enough text to be clearly visible when rendered ' +
'in the paginated layout engine viewport.',
);
const run = schema.nodes.run.create(null, text);
paragraphs.push(schema.nodes.paragraph.create(null, run));
}

const doc = schema.nodes.doc.create(null, paragraphs);
const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content);
editor.view.dispatch(tr);
});

await superdoc.waitForStable(2000);

// Verify multiple pages exist before proceeding.
const initialPageCount = await superdoc.page.locator('.superdoc-page[data-page-index]').count();
expect(initialPageCount).toBeGreaterThanOrEqual(3);

// Set zoom to 75%.
await superdoc.page.evaluate(() => {
(window as any).superdoc.setZoom(75);
});
await superdoc.waitForStable(1000);

// Scroll to mid-document.
await superdoc.page.evaluate(() => {
// Walk from the editor element up to find the scrollable ancestor.
const editor = document.querySelector('.superdoc-viewport') ?? document.querySelector('#editor');
let scrollable: HTMLElement | null = null;
let el: HTMLElement | null = editor as HTMLElement;
while (el && el !== document.documentElement) {
if (el.scrollHeight > el.clientHeight + 10) {
scrollable = el;
break;
}
el = el.parentElement;
}
if (!scrollable) {
scrollable = document.documentElement;
}
scrollable.scrollTop = Math.floor(scrollable.scrollHeight / 2);
});

await superdoc.waitForStable(1000);

// Pages should be mounted in the DOM.
const visiblePages = superdoc.page.locator('.superdoc-page[data-page-index]');
await expect(visiblePages.first()).toBeAttached({ timeout: 5000 });

// Mounted pages must contain visible content lines (not blank).
const linesInView = superdoc.page.locator('.superdoc-page .superdoc-line');
await expect(linesInView.first()).toBeAttached({ timeout: 5000 });
const lineCount = await linesInView.count();
expect(lineCount).toBeGreaterThan(0);

// At least one line must have non-empty text.
const hasVisibleText = await superdoc.page.evaluate(() => {
const lines = document.querySelectorAll('.superdoc-page .superdoc-line');
for (const line of lines) {
if ((line.textContent ?? '').trim().length > 0) return true;
}
return false;
});
expect(hasVisibleText).toBe(true);

// Mounted pages should include mid-document pages, not just the first page.
const pageIndices = await superdoc.page.evaluate(() => {
const pages = document.querySelectorAll('.superdoc-page[data-page-index]');
return Array.from(pages).map((p) => Number((p as HTMLElement).dataset.pageIndex));
});
const maxPageIndex = Math.max(...pageIndices);
expect(maxPageIndex).toBeGreaterThanOrEqual(1);
});
Loading