diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index d3913a50e0..1c07ad8395 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2053,6 +2053,58 @@ 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; + + // 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; + + // 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). * @@ -3503,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') { @@ -3654,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; @@ -4403,11 +4472,17 @@ export class PresentationEditor extends EventEmitter { const layout = this.#layoutState.layout; if (!layout) return; - const pageHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - const virtualGap = this.#layoutOptions.virtualization?.gap ?? 0; + const defaultHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; + const virtualGap = this.#getEffectivePageGap(); - // 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..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 @@ -388,6 +388,203 @@ 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('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) => { + 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({