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
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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).
*
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading