From 556f6bdbd9d9673ddcc930f908f16830b67f6671 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 25 Feb 2026 10:45:21 -0300 Subject: [PATCH 1/4] fix(virtualization): correct scroll mapping and viewport sizing at non-100% zoom Fix blank pages and excess scroll space when zoomed out in long documents. Three interrelated bugs caused the virtualization system to break at zoom != 100%: 1. updateVirtualWindow() used getBoundingClientRect().top (screen-space, affected by CSS transform: scale) but compared against virtualOffsets (layout-space, unscaled). Divide by zoom factor to fix. 2. PresentationEditor used 24px page gap for viewport height calculation while DomPainter virtualization defaulted to 72px. Normalize the virtualization gap to match the effective page gap. 3. CSS transform: scale() does not change layout box dimensions. At zoom < 1, painterHost's unscaled CSS box overflowed viewportHost, inflating the scroll range. Use explicit height + overflow: hidden on viewportHost to clip the CSS box. --- .../layout-engine/painters/dom/src/index.ts | 5 +++ .../painters/dom/src/renderer.ts | 33 ++++++++++++++-- .../presentation-editor/PresentationEditor.ts | 38 ++++++++++++++++--- 3 files changed, 68 insertions(+), 8 deletions(-) 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 fa3cf90e60..a107df767d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1024,6 +1024,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; @@ -1081,6 +1083,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. @@ -1610,16 +1632,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/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 62d3634ee3..11272ce2f7 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2213,6 +2213,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 @@ -3377,17 +3379,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; } @@ -4849,7 +4864,9 @@ 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.minHeight = ''; + this.#viewportHost.style.height = `${scaledHeight}px`; + this.#viewportHost.style.overflow = 'hidden'; this.#viewportHost.style.transform = ''; this.#painterHost.style.width = `${totalWidth}px`; @@ -4872,13 +4889,24 @@ export class PresentationEditor extends EventEmitter { // // This ensures the scroll container sees the correct scaled content size while // the transform provides visual scaling. + // + // IMPORTANT: 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. Without overflow: hidden, the CSS box would push viewportHost + // taller than intended, creating extra scrollable space at the bottom. + // Using explicit height + overflow: hidden ensures the scroll container sees only + // the scaled visual size. const scaledWidth = maxWidth * zoom; const scaledHeight = totalHeight * zoom; - // Set viewport to scaled dimensions for scroll container + // Set viewport to scaled dimensions for scroll container. + // Use explicit height (not just minHeight) with overflow: hidden to prevent + // painterHost's unscaled CSS box from inflating the scroll range. this.#viewportHost.style.width = `${scaledWidth}px`; this.#viewportHost.style.minWidth = `${scaledWidth}px`; - this.#viewportHost.style.minHeight = `${scaledHeight}px`; + this.#viewportHost.style.minHeight = ''; + this.#viewportHost.style.height = `${scaledHeight}px`; + this.#viewportHost.style.overflow = 'hidden'; this.#viewportHost.style.transform = ''; // Set painterHost to UNSCALED dimensions and apply transform From a10eaf2e41cf7b18db03d2326cb5c04fbc30cb72 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 25 Feb 2026 11:04:59 -0300 Subject: [PATCH 2/4] fix(zoom): ensure zoom controls work on blank documents Blank documents created by the store lacked an `id`, causing the PresentationEditor instance to not register in the static `#instances` map. When `setGlobalZoom()` iterated the map it found nothing, so zoom CSS transforms were never applied. Two fixes: - Add `id: uuidv4()` to the blank document config in superdoc-store - Always register PresentationEditor instances with a fallback key when `documentId` is not provided, ensuring `setGlobalZoom` works regardless of document configuration --- .../src/core/presentation-editor/PresentationEditor.ts | 10 ++++++---- packages/superdoc/src/stores/superdoc-store.js | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 11272ce2f7..15584e59a8 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -610,10 +610,12 @@ 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. + // Generate a fallback ID when documentId is not provided (e.g., blank documents) + // so that setGlobalZoom() can always find and update all instances. + const registryKey = options.documentId || `__anonymous_${Date.now()}_${Math.random().toString(36).slice(2)}`; + this.#options.documentId = registryKey; + PresentationEditor.#instances.set(registryKey, this); this.#pendingDocChange = true; this.#scheduleRerender(); diff --git a/packages/superdoc/src/stores/superdoc-store.js b/packages/superdoc/src/stores/superdoc-store.js index 12140c3827..cac8082782 100644 --- a/packages/superdoc/src/stores/superdoc-store.js +++ b/packages/superdoc/src/stores/superdoc-store.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia'; import { ref, reactive, computed } from 'vue'; +import { v4 as uuidv4 } from 'uuid'; import { useCommentsStore } from './comments-store'; import { getFileObject, DOCX, PDF } from '@superdoc/common'; import { normalizeDocumentEntry } from '@superdoc/core/helpers/file.js'; @@ -78,6 +79,7 @@ export const useSuperdocStore = defineStore('superdoc', () => { if (!configDocs?.length && !config.modules.collaboration) { const newDoc = await getFileObject(BlankDOCX, 'blank.docx', DOCX); const newDocConfig = { + id: uuidv4(), type: DOCX, data: newDoc, name: 'blank.docx', From 1b54b6eb7dbe13582c734e5362242eb83622256a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 27 Feb 2026 14:20:10 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(virtualization):=20address=20PR=20revie?= =?UTF-8?q?w=20=E2=80=94=20registryKey,=20overflow=20clipping,=20zoom=20te?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace options.documentId mutation with #registryKey private field - Replace overflow:hidden with negative margin-bottom on painterHost to avoid clipping collaboration cursor labels - Add zoom × virtualization interaction test for non-scrollable container --- .../painters/dom/src/virtualization.test.ts | 56 +++++++++++++++++++ .../presentation-editor/PresentationEditor.ts | 52 +++++++++-------- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index add592c219..4bfd801a6a 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -369,6 +369,62 @@ 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 should be around index 8-9 + // (each page is 500+72=572px, so page 8 starts at 8*572=4576). + const pages = mount.querySelectorAll('.superdoc-page'); + const indices = Array.from(pages).map((p) => Number((p as HTMLElement).dataset.pageIndex)); + + // The anchor page for scroll=5000 should be 8 (topOfIndex(9)=5148 > 5000, topOfIndex(8)=4576 <= 5000). + // With window=3, we expect pages around index 7-9 or 8-10. + expect(indices.some((i) => i >= 7 && i <= 10)).toBe(true); + + // Verify the window doesn't include page 0 (would indicate uncorrected zoom — scrollY=3750 → page 6). + // With correct zoom handling, we shouldn't see early pages. + expect(indices.every((i) => i >= 5)).toBe(true); + }); + 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 010c4a78f7..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; @@ -611,11 +613,10 @@ export class PresentationEditor extends EventEmitter { this.#syncTrackedChangesPreferences(); // Register this instance in the static registry. - // Generate a fallback ID when documentId is not provided (e.g., blank documents) - // so that setGlobalZoom() can always find and update all instances. - const registryKey = options.documentId || `__anonymous_${Date.now()}_${Math.random().toString(36).slice(2)}`; - this.#options.documentId = registryKey; - PresentationEditor.#instances.set(registryKey, this); + // 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(); @@ -2302,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 @@ -4881,13 +4883,17 @@ export class PresentationEditor extends EventEmitter { this.#viewportHost.style.width = `${scaledWidth}px`; this.#viewportHost.style.minWidth = `${scaledWidth}px`; - this.#viewportHost.style.minHeight = ''; - this.#viewportHost.style.height = `${scaledHeight}px`; - this.#viewportHost.style.overflow = 'hidden'; + 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})`; @@ -4907,29 +4913,29 @@ export class PresentationEditor extends EventEmitter { // This ensures the scroll container sees the correct scaled content size while // the transform provides visual scaling. // - // IMPORTANT: CSS transform: scale() does NOT change the element's CSS box dimensions. + // 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. Without overflow: hidden, the CSS box would push viewportHost - // taller than intended, creating extra scrollable space at the bottom. - // Using explicit height + overflow: hidden ensures the scroll container sees only - // the scaled visual size. + // 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. - // Use explicit height (not just minHeight) with overflow: hidden to prevent - // painterHost's unscaled CSS box from inflating the scroll range. this.#viewportHost.style.width = `${scaledWidth}px`; this.#viewportHost.style.minWidth = `${scaledWidth}px`; - this.#viewportHost.style.minHeight = ''; - this.#viewportHost.style.height = `${scaledHeight}px`; - this.#viewportHost.style.overflow = 'hidden'; + 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})`; From de1799ad824582c8de0031dea902149cdd82a719 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 2 Mar 2026 18:30:46 -0300 Subject: [PATCH 4/4] test(virtualization): tighten zoom assertions and add behavior test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the zoom × virtualization unit test to assert exact page indices [7, 8, 9] instead of loose range checks that pass on buggy behavior. Add a Playwright behavior test that generates a long document, sets 75% zoom, scrolls to mid-document, and verifies content is visible (not blank). This guards against regressions when virtualization or zoom code changes. --- .../painters/dom/src/virtualization.test.ts | 13 +-- .../zoom-scroll-visibility.spec.ts | 88 +++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 tests/behavior/tests/virtualization/zoom-scroll-visibility.spec.ts diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 4bfd801a6a..40998def70 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -411,18 +411,13 @@ describe('DomPainter virtualization (vertical)', () => { painter.onScroll!(); - // At layoutScrollY=5000, the anchor page should be around index 8-9 - // (each page is 500+72=572px, so page 8 starts at 8*572=4576). + // 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)); - // The anchor page for scroll=5000 should be 8 (topOfIndex(9)=5148 > 5000, topOfIndex(8)=4576 <= 5000). - // With window=3, we expect pages around index 7-9 or 8-10. - expect(indices.some((i) => i >= 7 && i <= 10)).toBe(true); - - // Verify the window doesn't include page 0 (would indicate uncorrected zoom — scrollY=3750 → page 6). - // With correct zoom handling, we shouldn't see early pages. - expect(indices.every((i) => i >= 5)).toBe(true); + expect(indices).toEqual([7, 8, 9]); }); it('renders drawing fragments inside virtualized windows', () => { 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); +});