diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d72de0811f..ffc50b6432 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -499,7 +499,8 @@ export class PresentationEditor extends EventEmitter { #semanticResizeDebounce: number | null = null; #lastSemanticContainerWidth: number | null = null; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; - #scrollHandler: (() => void) | null = null; + #scrollHandler: ((event?: Event) => void) | null = null; + #handledScrollEvents = new WeakSet(); #scrollContainer: Element | Window | null = null; #scrollContainerValidated = false; #sectionMetadata: SectionMetadata[] = []; @@ -4070,11 +4071,12 @@ export class PresentationEditor extends EventEmitter { if (this.#scrollHandler) { if (this.#scrollContainer) { - this.#scrollContainer.removeEventListener('scroll', this.#scrollHandler); + this.#scrollContainer.removeEventListener('scroll', this.#scrollHandler, { capture: true }); } const win = this.#visibleHost?.ownerDocument?.defaultView; - win?.removeEventListener('scroll', this.#scrollHandler); + win?.removeEventListener('scroll', this.#scrollHandler, { capture: true }); this.#scrollHandler = null; + this.#handledScrollEvents = new WeakSet(); this.#scrollContainer = null; } this.#inputBridge?.notifyTargetChanged(); @@ -4797,20 +4799,25 @@ export class PresentationEditor extends EventEmitter { // Scroll handler for virtualization - find the actual scroll container // by walking up the DOM tree to find the first scrollable ancestor - this.#scrollHandler = () => { + this.#handledScrollEvents = new WeakSet(); + this.#scrollHandler = (event?: Event) => { + if (event) { + if (this.#handledScrollEvents.has(event)) return; + this.#handledScrollEvents.add(event); + } this.#painterAdapter.onScroll(); }; // Find the scrollable ancestor and attach listener there this.#scrollContainer = this.#findScrollableAncestor(this.#visibleHost); if (this.#scrollContainer) { - this.#scrollContainer.addEventListener('scroll', this.#scrollHandler, { passive: true }); + this.#scrollContainer.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true }); } // Also listen on window as fallback const win = this.#visibleHost.ownerDocument?.defaultView; if (win && this.#scrollContainer !== win) { - win.addEventListener('scroll', this.#scrollHandler, { passive: true }); + win.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true }); } } @@ -4887,11 +4894,11 @@ export class PresentationEditor extends EventEmitter { if (!next || next === this.#scrollContainer) return; const prev = this.#scrollContainer; - prev.removeEventListener('scroll', this.#scrollHandler!); + prev.removeEventListener('scroll', this.#scrollHandler!, { capture: true }); this.#scrollContainer = next; if (next instanceof Element) { - next.addEventListener('scroll', this.#scrollHandler!, { passive: true }); + next.addEventListener('scroll', this.#scrollHandler!, { passive: true, capture: true }); } this.#painterAdapter.setScrollContainer(next instanceof HTMLElement ? next : null); } diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index da28b39b8b..f1a7489d5c 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -27,6 +27,8 @@ interface HarnessConfig { showSelection?: boolean; allowSelectionInViewMode?: boolean; documentMode?: 'editing' | 'viewing' | 'suggesting'; + previewScroll?: boolean; + blockPreviewScrollEvents?: boolean; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; @@ -63,6 +65,8 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); if (config.allowSelectionInViewMode) params.set('allowSelectionInViewMode', '1'); if (config.documentMode) params.set('documentMode', config.documentMode); + if (config.previewScroll) params.set('previewScroll', '1'); + if (config.blockPreviewScrollEvents) params.set('blockPreviewScrollEvents', '1'); const qs = params.toString(); return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 581b1a1994..010bdc4ac1 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -73,11 +73,30 @@ const allowSelectionInViewMode = params.get('allowSelectionInViewMode') === '1'; const documentMode = params.get('documentMode') as 'editing' | 'viewing' | 'suggesting' | null; const contentOverride = params.get('contentOverride') ?? undefined; const overrideType = (params.get('overrideType') as OverrideType | null) ?? undefined; +const previewScroll = params.get('previewScroll') === '1'; +const blockPreviewScrollEvents = params.get('blockPreviewScrollEvents') === '1'; if (!showCaret) { document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); } +if (previewScroll) { + const harnessMain = document.querySelector('#harness-main'); + if (harnessMain) { + harnessMain.style.height = '720px'; + harnessMain.style.overflowY = 'auto'; + if (blockPreviewScrollEvents) { + harnessMain.addEventListener( + 'scroll', + (event) => { + event.stopImmediatePropagation(); + }, + { capture: true }, + ); + } + } +} + let instance: SuperDocInstance | null = null; const commentsPanel = document.querySelector('#comments-panel'); diff --git a/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts b/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts new file mode 100644 index 0000000000..0fcab4322f --- /dev/null +++ b/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'none', previewScroll: true, blockPreviewScrollEvents: true } }); + +async function generateLongDocument(page: any, paragraphCount = 200): Promise { + await page.evaluate((count: number) => { + const editor = (window as any).editor; + const { state } = editor; + const { schema } = state; + + const paragraphs: any[] = []; + for (let i = 0; i < count; i++) { + const text = schema.text( + `SD-3230 paragraph ${i + 1}. ` + + 'This document is intentionally long enough to require a virtualized page window. ' + + 'The visible pages should update when the scroll owner moves.', + ); + 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); + }, paragraphCount); +} + +async function jumpPastInitialVirtualWindow(page: any): Promise { + await page.evaluate(() => { + const editor = document.querySelector('.superdoc-viewport') ?? document.querySelector('#editor'); + let scrollOwner: HTMLElement | null = editor as HTMLElement; + + while (scrollOwner && scrollOwner !== document.documentElement) { + if (scrollOwner.scrollHeight > scrollOwner.clientHeight + 10) break; + scrollOwner = scrollOwner.parentElement; + } + + if (!scrollOwner || scrollOwner === document.documentElement) { + throw new Error('Expected a SuperDoc scroll owner for the virtualization regression test.'); + } + + scrollOwner.scrollTop = Math.floor(scrollOwner.clientHeight * 8); + }); +} + +async function getVirtualizedViewportState(page: any): Promise<{ + scrollTop: number; + mountedPages: number[]; + visibleText: string; +}> { + return page.evaluate(() => { + const editor = document.querySelector('.superdoc-viewport') ?? document.querySelector('#editor'); + let scrollOwner: HTMLElement | null = editor as HTMLElement; + + while (scrollOwner && scrollOwner !== document.documentElement) { + if (scrollOwner.scrollHeight > scrollOwner.clientHeight + 10) break; + scrollOwner = scrollOwner.parentElement; + } + + if (!scrollOwner || scrollOwner === document.documentElement) { + throw new Error('Expected a SuperDoc scroll owner for the virtualization regression test.'); + } + + const mountedPages = Array.from(document.querySelectorAll('.superdoc-page[data-page-index]')) + .map((pageEl) => Number((pageEl as HTMLElement).dataset.pageIndex)) + .sort((a, b) => a - b); + + const ownerRect = scrollOwner.getBoundingClientRect(); + const visibleText = Array.from(document.querySelectorAll('.superdoc-page[data-page-index]')) + .filter((pageEl) => { + const rect = pageEl.getBoundingClientRect(); + return rect.bottom > ownerRect.top && rect.top < ownerRect.bottom; + }) + .map((pageEl) => pageEl.textContent?.trim() ?? '') + .join('\n') + .trim(); + + return { + scrollTop: scrollOwner.scrollTop, + mountedPages, + visibleText, + }; + }); +} + +test('virtualized pages follow a host scroll owner that stops scroll propagation', async ({ superdoc }) => { + await generateLongDocument(superdoc.page); + await superdoc.waitForStable(2000); + + await jumpPastInitialVirtualWindow(superdoc.page); + await superdoc.waitForStable(500); + + const state = await getVirtualizedViewportState(superdoc.page); + + expect(state.scrollTop).toBeGreaterThan(0); + expect(Math.min(...state.mountedPages)).toBeGreaterThan(0); + expect(state.visibleText).toContain('SD-3230 paragraph'); +});