fix(reader): revert smooth mouse-wheel scrolling in scroll mode, closes #4130 (#4172)

The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel
events in scroll mode: it makes the wheel listener non-passive,
preventDefault()s the native scroll, and replays the delta through a
main-thread rAF animation against the renderer container.

That regressed normal mouse scrolling on Windows (#4130): fast wheel
bursts were discarded entirely, and the JS replay is structurally worse
than native scrolling -- a non-passive wheel listener forces every wheel
event (mouse and trackpad) off the compositor thread, and the
postMessage hop plus main-thread animation add latency and jank that
native compositor scrolling does not have.

High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966)
needs no special API: the OS/driver just delivers regular wheel events
with smaller, more frequent deltas, and the browser scrolls them
natively. #3966's own report ("smooth scrolling works with all
applications apart from yours") points at the interception, not a
missing capability. Restore native wheel scrolling in scroll mode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-15 21:28:57 +08:00
committed by GitHub
parent 4cd5d56b49
commit f5e729a174
5 changed files with 17 additions and 313 deletions
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { FoliateView } from '@/types/view';
import { ViewSettings } from '@/types/book';
@@ -9,7 +9,6 @@ import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
import { getReadingRulerMoveDirection } from '../utils/readingRuler';
import { SmoothScroller, type SmoothScrollTarget } from '../utils/smoothWheelScroll';
import { useTouchInterceptor } from './useTouchInterceptor';
export type ScrollSource = 'touch' | 'mouse';
@@ -111,16 +110,6 @@ export const usePagination = (
const { getViewSettings, getViewState } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const smoothScrollerRef = useRef<SmoothScroller | null>(null);
const smoothScrollTargetRef = useRef<SmoothScrollTarget>({
get position() {
return viewRef.current?.renderer.containerPosition ?? 0;
},
set position(value: number) {
const renderer = viewRef.current?.renderer;
if (renderer) renderer.containerPosition = value;
},
});
const handlePageFlip = async (
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
@@ -199,37 +188,22 @@ export const usePagination = (
viewPagination(viewRef.current, viewSettings, side);
}
}
} else if (msg.data.type === 'iframe-wheel') {
const { deltaY, deltaX, isMouseWheel } = msg.data;
if (
viewSettings.scrolled &&
isMouseWheel &&
!isPanningView(viewRef.current, viewSettings)
) {
// Mouse wheels deliver one large quantised delta per notch which
// Chromium would scroll without interpolation, producing the
// jerky one-step-per-frame motion reported on Windows. The
// iframe handler already preventDefault'd the native scroll —
// here we replay the delta as a smooth animation instead.
if (!smoothScrollerRef.current) {
smoothScrollerRef.current = new SmoothScroller();
}
smoothScrollerRef.current.scrollBy(smoothScrollTargetRef.current, deltaY);
} else if (!viewSettings.scrolled && !isPanningView(viewRef.current, viewSettings)) {
// Paginated mode: wheel always flips a page (the iframe doesn't
// scroll because the container has overflow:hidden).
if (deltaY > 0) {
viewPagination(viewRef.current, viewSettings, 'down');
} else if (deltaY < 0) {
viewPagination(viewRef.current, viewSettings, 'up');
} else if (deltaX < 0) {
viewPagination(viewRef.current, viewSettings, 'left');
} else if (deltaX > 0) {
viewPagination(viewRef.current, viewSettings, 'right');
}
} else if (
msg.data.type === 'iframe-wheel' &&
!viewSettings.scrolled &&
!isPanningView(viewRef.current, viewSettings)
) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY, deltaX } = msg.data;
if (deltaY > 0) {
viewPagination(viewRef.current, viewSettings, 'down');
} else if (deltaY < 0) {
viewPagination(viewRef.current, viewSettings, 'up');
} else if (deltaX < 0) {
viewPagination(viewRef.current, viewSettings, 'left');
} else if (deltaX > 0) {
viewPagination(viewRef.current, viewSettings, 'right');
}
// Otherwise (scrolled mode + trackpad/high-resolution input) the
// browser's native scroll already runs and is pixel-precise.
} else if (msg.data.type === 'iframe-mouseup') {
if (msg.data.button === 3) {
viewRef.current?.history.back();
@@ -273,12 +247,6 @@ export const usePagination = (
}
};
useEffect(() => {
return () => {
smoothScrollerRef.current?.cancel();
};
}, []);
useEffect(() => {
if (!appService?.isMobileApp) return;