fix(reader): smooth single-notch wheel scroll over PDF pages in scrolled mode (#4727) (#4732)

Bumps foliate-js to drop the redundant manual `scrollBy` the scrolled-mode
page iframes ran on every wheel event. Because those iframes are
`scrolling="no"`, the browser already chains the wheel to the host scroller
natively; the extra scrollBy stacked on top, so wheeling over a page moved
it ~2x as far in an instant lurch while the margins scrolled smoothly by one
notch. Native scroll-chaining now provides the single smooth scroll over both
the page and the margins.

Adds a browser-lane regression test that mounts the real <foliate-fxl>
renderer in scrolled mode and asserts a wheel over a page does not
programmatically move the host scroller.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 23:17:24 +08:00
committed by GitHub
parent acf2b165f3
commit 1b44b95d3a
2 changed files with 85 additions and 1 deletions
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from 'vitest';
// Registers the <foliate-fxl> custom element (the fixed-layout / PDF renderer).
import 'foliate-js/fixed-layout.js';
// Regression test for readest#4727: in fixed-layout scroll mode, a wheel tick
// that lands on a page iframe (during the brief idle window where the iframe is
// interactive) must NOT be scrolled by JS. The browser already chains the wheel
// to the host scroller natively; if the in-iframe wheel handler also scrolls the
// host, the page travels twice as far in an instant lurch instead of one smooth
// notch.
//
// This needs a real browser: the bug is the interaction between native wheel
// scrolling and a programmatic scrollBy, and jsdom has no layout/scroll. We
// mount the real renderer and assert the JS-side handler does not move the host
// scroller on its own (the synthetic wheel does not trigger native scrolling,
// so any movement we observe is purely the JS handler — which must be zero).
const PAGE_HTML = `<!doctype html><html><head><style>
html, body { margin: 0; height: 1000px; background: linear-gradient(#fff, #ccc); }
</style></head><body></body></html>`;
const makeBook = (sectionCount: number) => ({
dir: 'ltr',
rendition: { viewport: { width: 600, height: 1000 }, spread: 'none' },
sections: Array.from({ length: sectionCount }, () => ({
// `src` only has to be truthy — when `data` is present the renderer loads it
// via srcdoc, which keeps the iframe same-origin so its document (and the
// wheel listener attached to it) is reachable.
load: async () => ({ src: 'srcdoc', data: PAGE_HTML }),
linear: 'yes',
})),
});
const waitFor = async <T>(fn: () => T | null | undefined, timeout = 4000): Promise<T> => {
const start = performance.now();
for (;;) {
const value = fn();
if (value) return value;
if (performance.now() - start > timeout) throw new Error('waitFor timed out');
await new Promise((r) => setTimeout(r, 30));
}
};
let renderer: HTMLElement | null = null;
afterEach(() => {
renderer?.remove();
renderer = null;
});
describe('fixed-layout scroll mode wheel handling (readest#4727)', () => {
it('does not double-scroll the host when a wheel lands on a page', async () => {
renderer = document.createElement('foliate-fxl');
renderer.style.width = '600px';
renderer.style.height = '400px';
renderer.setAttribute('flow', 'scrolled');
document.body.append(renderer);
(renderer as unknown as { open(book: unknown): void }).open(makeBook(3));
// The first page loads via the IntersectionObserver; wait until its iframe
// has rendered (display flipped from 'none') — by then the wheel listener
// that #loadScrollPage attaches to the iframe document is in place.
const iframe = await waitFor(() => {
const f = renderer!.shadowRoot?.querySelector<HTMLIFrameElement>('.scroll-page iframe');
return f && f.style.display && f.style.display !== 'none' && f.contentDocument ? f : null;
});
const scroller = renderer as unknown as HTMLElement;
scroller.scrollTop = 0;
// A wheel over the page iframe. With the bug, the iframe handler runs
// `host.scrollBy({ top: deltaY })`, moving the host scroller by ~120px on
// top of the (here absent) native scroll. With the fix it must stay put.
iframe.contentDocument!.dispatchEvent(
new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }),
);
await new Promise((r) => setTimeout(r, 60));
expect(scroller.scrollTop).toBe(0);
});
});