From 76f836b16b7c9f6d84f745bf2e18303a1b32bbd4 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Tue, 20 Jan 2026 12:27:08 -0500 Subject: [PATCH 1/4] feat: add public scroll to page method --- .../presentation-editor/PresentationEditor.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 69815ff6ed..549b0049aa 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -1978,6 +1978,55 @@ export class PresentationEditor extends EventEmitter { } } + /** + * Scrolls a specific page into view. + * + * This method supports virtualized rendering: if the target page is not currently + * mounted in the DOM, it will scroll to the computed y-position to trigger + * virtualization, wait for the page to mount, then perform precise scrolling. + * + * @param pageNumber - One-based page number to scroll to (e.g., 1 for first page) + * @param scrollBehavior - Scroll behavior ('auto' | 'smooth'). Defaults to 'smooth'. + * @returns Promise resolving to true if the page was scrolled to, false if layout not available or invalid page + * + * @example + * ```typescript + * // Smooth scroll to first page + * await presentationEditor.scrollToPage(1); + * + * // Instant scroll to page 5 + * await presentationEditor.scrollToPage(5, 'auto'); + * ``` + */ + async scrollToPage(pageNumber: number, scrollBehavior: ScrollBehavior = 'smooth'): Promise { + const layout = this.#layoutState.layout; + if (!layout) return false; + + // Convert 1-based page number to 0-based index + const pageIndex = pageNumber - 1; + + // Clamp to valid page range + const maxPage = layout.pages.length - 1; + if (pageIndex < 0 || pageIndex > maxPage) return false; + + // Check if page is already mounted + let pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); + + // If not mounted (virtualized), scroll to computed y-position to trigger mount + if (!pageEl) { + this.#scrollPageIntoView(pageIndex); + const mounted = await this.#waitForPageMount(pageIndex, { timeout: 2000 }); + if (!mounted) return false; + pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); + } + + if (pageEl) { + pageEl.scrollIntoView({ block: 'start', inline: 'nearest', behavior: scrollBehavior }); + return true; + } + return false; + } + /** * Get document position from viewport coordinates (header/footer-aware). * From 7f0f604a0141262118bd98f3f8affbea24611b4b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 15:14:34 -0800 Subject: [PATCH 2/4] fix(presentation-editor): use per-page offsets and validate input in scrollToPage --- .../presentation-editor/PresentationEditor.ts | 15 +- .../tests/PresentationEditor.test.ts | 135 ++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index f1a4f28fb4..f4fcdb2f6b 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2077,6 +2077,9 @@ export class PresentationEditor extends EventEmitter { const layout = this.#layoutState.layout; if (!layout) return false; + // Reject non-finite or non-integer input to fail fast instead of timing out + if (!Number.isInteger(pageNumber)) return false; + // Convert 1-based page number to 0-based index const pageIndex = pageNumber - 1; @@ -4452,11 +4455,17 @@ export class PresentationEditor extends EventEmitter { const layout = this.#layoutState.layout; if (!layout) return; - const pageHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; + const defaultHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; const virtualGap = this.#layoutOptions.virtualization?.gap ?? 0; - // Calculate approximate y position for the page - const yPosition = pageIndex * (pageHeight + virtualGap); + // Use cumulative per-page heights so mixed-size documents scroll to the + // correct position. The renderer's virtualizer uses the same prefix-sum + // approach, so the scroll position lands inside the correct window. + let yPosition = 0; + for (let i = 0; i < pageIndex; i++) { + const pageHeight = layout.pages[i]?.size?.h ?? defaultHeight; + yPosition += pageHeight + virtualGap; + } // Scroll viewport to the calculated position if (this.#visibleHost) { diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index 4efa2e87ab..e030ea25d5 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -388,6 +388,141 @@ describe('PresentationEditor', () => { }); }); + describe('scrollToPage', () => { + const buildMixedPageLayout = () => ({ + layout: { + pageSize: { w: 612, h: 600 }, + pageGap: 10, + pages: [ + { + number: 1, + size: { w: 612, h: 600 }, + fragments: [], + }, + { + number: 2, + size: { w: 612, h: 1200 }, + fragments: [], + }, + { + number: 3, + size: { w: 612, h: 400 }, + fragments: [], + }, + ], + }, + measures: [], + }); + + it('mounts and scrolls to virtualized pages using cumulative mixed-height offsets', async () => { + mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout()); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-scroll-to-page-mixed-heights', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + layoutEngineOptions: { + virtualization: { enabled: true, gap: 10, window: 1, overscan: 0 }, + }, + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const expectedPageTop = 600 + 10 + 1200 + 10; + let mountedPageEl: HTMLElement | null = null; + let scrollTopValue = 0; + Object.defineProperty(container, 'scrollTop', { + get: () => scrollTopValue, + set: (next) => { + scrollTopValue = Number(next); + if (!mountedPageEl && Math.abs(scrollTopValue - expectedPageTop) < 0.5) { + mountedPageEl = document.createElement('div'); + mountedPageEl.setAttribute('data-page-index', '2'); + Object.defineProperty(mountedPageEl, 'scrollIntoView', { + value: vi.fn(), + configurable: true, + }); + pagesHost.appendChild(mountedPageEl); + } + }, + configurable: true, + }); + + let now = 0; + const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + now += 100; + cb(now); + return 1; + }); + + try { + const didScroll = await editor.scrollToPage(3, 'auto'); + + expect(didScroll).toBe(true); + expect(mountedPageEl).not.toBeNull(); + expect(mountedPageEl!.scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + behavior: 'auto', + }); + } finally { + rafSpy.mockRestore(); + performanceNowSpy.mockRestore(); + } + }); + + it.each([Number.NaN, 1.5])( + 'rejects invalid pageNumber %p before attempting pre-scroll or mount polling', + async (invalidPageNumber) => { + mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout()); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-scroll-to-page-invalid-input', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + layoutEngineOptions: { + virtualization: { enabled: true, gap: 10, window: 1, overscan: 0 }, + }, + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + let scrollTopValue = 0; + let scrollWrites = 0; + Object.defineProperty(container, 'scrollTop', { + get: () => scrollTopValue, + set: (next) => { + scrollWrites += 1; + scrollTopValue = Number(next); + }, + configurable: true, + }); + + let now = 0; + const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + now += 2500; + cb(now); + return 1; + }); + + try { + const didScroll = await editor.scrollToPage(invalidPageNumber, 'auto'); + expect(didScroll).toBe(false); + expect(scrollWrites).toBe(0); + expect(rafSpy).not.toHaveBeenCalled(); + } finally { + rafSpy.mockRestore(); + performanceNowSpy.mockRestore(); + } + }, + ); + }); + describe('setDocumentMode', () => { it('should initialize with editing mode by default', () => { editor = new PresentationEditor({ From d8145ea3a011bbfaf549e1f0327fb84ffbf9068b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 15:25:23 -0800 Subject: [PATCH 3/4] fix: use cumulative page offsets and effective gap in scrollToPage pre-scroll --- .../presentation-editor/PresentationEditor.ts | 2 +- .../tests/PresentationEditor.test.ts | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index f4fcdb2f6b..18b465f32f 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4456,7 +4456,7 @@ export class PresentationEditor extends EventEmitter { if (!layout) return; const defaultHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - const virtualGap = this.#layoutOptions.virtualization?.gap ?? 0; + const virtualGap = this.#getEffectivePageGap(); // Use cumulative per-page heights so mixed-size documents scroll to the // correct position. The renderer's virtualizer uses the same prefix-sum diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index e030ea25d5..b174af528a 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -474,6 +474,68 @@ describe('PresentationEditor', () => { } }); + it('uses effective virtualization default gap when pre-scrolling to unmounted pages', async () => { + mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout()); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-scroll-to-page-default-virtual-gap', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + layoutEngineOptions: { + // Intentionally omit `gap` so editor must rely on the effective default. + virtualization: { enabled: true, window: 1, overscan: 0 }, + }, + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const layoutGap = editor.getLayoutSnapshot().layout?.pageGap ?? 0; + const expectedPageTop = 600 + layoutGap + 1200 + layoutGap; + let mountedPageEl: HTMLElement | null = null; + let scrollTopValue = 0; + Object.defineProperty(container, 'scrollTop', { + get: () => scrollTopValue, + set: (next) => { + scrollTopValue = Number(next); + if (!mountedPageEl && Math.abs(scrollTopValue - expectedPageTop) < 0.5) { + mountedPageEl = document.createElement('div'); + mountedPageEl.setAttribute('data-page-index', '2'); + Object.defineProperty(mountedPageEl, 'scrollIntoView', { + value: vi.fn(), + configurable: true, + }); + pagesHost.appendChild(mountedPageEl); + } + }, + configurable: true, + }); + + let now = 0; + const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + now += 100; + cb(now); + return 1; + }); + + try { + const didScroll = await editor.scrollToPage(3, 'auto'); + + expect(didScroll).toBe(true); + expect(mountedPageEl).not.toBeNull(); + expect(mountedPageEl!.scrollIntoView).toHaveBeenCalledWith({ + block: 'start', + inline: 'nearest', + behavior: 'auto', + }); + } finally { + rafSpy.mockRestore(); + performanceNowSpy.mockRestore(); + } + }); + it.each([Number.NaN, 1.5])( 'rejects invalid pageNumber %p before attempting pre-scroll or mount polling', async (invalidPageNumber) => { From 9f5589723603c3bbca00fda3c3283dfd8cf3b372 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 15:28:32 -0800 Subject: [PATCH 4/4] chore: guard structured-content selection sync against malformed mocked selections --- .../presentation-editor/PresentationEditor.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 18b465f32f..1c07ad8395 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3555,7 +3555,12 @@ export class PresentationEditor extends EventEmitter { } node = selection.node; } else { - const $pos = selection.$from; + const $pos = (selection as Selection & { $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode } }) + .$from; + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + this.#clearSelectedStructuredContentBlockClass(); + return; + } for (let depth = $pos.depth; depth > 0; depth--) { const candidate = $pos.node(depth); if (candidate.type?.name === 'structuredContentBlock') { @@ -3706,10 +3711,22 @@ export class PresentationEditor extends EventEmitter { node = selection.node; pos = selection.from; } else { - const $pos = selection.$from; + const $pos = ( + selection as Selection & { + $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode; before?: (depth: number) => number }; + } + ).$from; + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + this.#clearSelectedStructuredContentInlineClass(); + return; + } for (let depth = $pos.depth; depth > 0; depth--) { const candidate = $pos.node(depth); if (candidate.type?.name === 'structuredContent') { + if (typeof $pos.before !== 'function') { + this.#clearSelectedStructuredContentInlineClass(); + return; + } node = candidate; pos = $pos.before(depth); break;