diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 7454e10a15..a011d00f95 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -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, @@ -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); + }, }; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f045773d33..a9caba6f96 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -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; @@ -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. @@ -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) diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index add592c219..40998def70 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -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], diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 590fadbca7..58f9a8e8ec 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -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; @@ -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(); @@ -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 @@ -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 @@ -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; } @@ -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})`; @@ -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})`; diff --git a/tests/behavior/tests/virtualization/zoom-scroll-visibility.spec.ts b/tests/behavior/tests/virtualization/zoom-scroll-visibility.spec.ts new file mode 100644 index 0000000000..bd8584f42e --- /dev/null +++ b/tests/behavior/tests/virtualization/zoom-scroll-visibility.spec.ts @@ -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); +});