Skip to content
Open
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 @@ -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<Event>();
#scrollContainer: Element | Window | null = null;
#scrollContainerValidated = false;
#sectionMetadata: SectionMetadata[] = [];
Expand Down Expand Up @@ -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<Event>();
this.#scrollContainer = null;
}
this.#inputBridge?.notifyTargetChanged();
Expand Down Expand Up @@ -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<Event>();
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 });
Comment thread
tupizz marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions tests/behavior/fixtures/superdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ interface HarnessConfig {
showSelection?: boolean;
allowSelectionInViewMode?: boolean;
documentMode?: 'editing' | 'viewing' | 'suggesting';
previewScroll?: boolean;
blockPreviewScrollEvents?: boolean;
}

type DocumentMode = 'editing' | 'suggesting' | 'viewing';
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 19 additions & 0 deletions tests/behavior/harness/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('#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<HTMLElement>('#comments-panel');

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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');
});
Loading