From f5e729a174140ab59737d76755205ebc1955cbba Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 15 May 2026 21:28:57 +0800 Subject: [PATCH] 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) --- .../reader/utils/smoothWheelScroll.test.ts | 151 ------------------ .../app/reader/components/FoliateViewer.tsx | 4 +- .../src/app/reader/hooks/usePagination.ts | 64 ++------ .../app/reader/utils/iframeEventHandlers.ts | 9 -- .../src/app/reader/utils/smoothWheelScroll.ts | 102 ------------ 5 files changed, 17 insertions(+), 313 deletions(-) delete mode 100644 apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts delete mode 100644 apps/readest-app/src/app/reader/utils/smoothWheelScroll.ts diff --git a/apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts b/apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts deleted file mode 100644 index 1f788f41..00000000 --- a/apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; -import { - SmoothScroller, - isLikelyMouseWheel, - type SmoothScrollTarget, -} from '@/app/reader/utils/smoothWheelScroll'; - -describe('isLikelyMouseWheel', () => { - test('treats line-mode wheel events as mouse wheel regardless of magnitude', () => { - expect(isLikelyMouseWheel({ deltaMode: 1, deltaX: 0, deltaY: 1 })).toBe(true); - expect(isLikelyMouseWheel({ deltaMode: 1, deltaX: 0, deltaY: 3 })).toBe(true); - }); - - test('returns false when there is no vertical motion', () => { - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 0 })).toBe(false); - }); - - test('treats two-axis pixel motion as trackpad', () => { - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 8, deltaY: 200 })).toBe(false); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: -1, deltaY: 100 })).toBe(false); - }); - - test('treats small per-event pixel deltas as trackpad even on a single axis', () => { - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 4 })).toBe(false); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 49 })).toBe(false); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: -49 })).toBe(false); - }); - - test('treats large single-axis pixel deltas as mouse wheel', () => { - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 100 })).toBe(true); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 120 })).toBe(true); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: -100 })).toBe(true); - expect(isLikelyMouseWheel({ deltaMode: 0, deltaX: 0, deltaY: 53 })).toBe(true); - }); -}); - -const flushFrames = async (frames: number, advanceMs = 16) => { - for (let i = 0; i < frames; i++) { - vi.advanceTimersByTime(advanceMs); - await Promise.resolve(); - } -}; - -describe('SmoothScroller', () => { - let target: SmoothScrollTarget; - let now: number; - - beforeEach(() => { - vi.useFakeTimers(); - now = 0; - vi.spyOn(performance, 'now').mockImplementation(() => now); - let raf = 0; - const handles = new Map(); - vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => { - raf += 1; - handles.set(raf, cb); - setTimeout(() => { - const fn = handles.get(raf); - if (fn) { - handles.delete(raf); - now += 16; - fn(now); - } - }, 16); - return raf; - }) as typeof requestAnimationFrame); - vi.stubGlobal('cancelAnimationFrame', ((id: number) => { - handles.delete(id); - }) as typeof cancelAnimationFrame); - - let position = 0; - target = { - get position() { - return position; - }, - set position(value: number) { - position = value; - }, - }; - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - test('moves toward target without overshooting', async () => { - const scroller = new SmoothScroller(); - scroller.scrollBy(target, 200); - - await flushFrames(60); - - expect(target.position).toBeCloseTo(200, 1); - }); - - test('accumulates new deltas while animating', async () => { - const scroller = new SmoothScroller(); - scroller.scrollBy(target, 100); - await flushFrames(2); - scroller.scrollBy(target, 100); - - await flushFrames(120); - - expect(target.position).toBeCloseTo(200, 1); - }); - - test('zero delta is a no-op', () => { - const scroller = new SmoothScroller(); - scroller.scrollBy(target, 0); - expect(target.position).toBe(0); - }); - - test('cancel stops the animation', async () => { - const scroller = new SmoothScroller(); - scroller.scrollBy(target, 500); - await flushFrames(2); - const mid = target.position; - scroller.cancel(); - await flushFrames(20); - expect(target.position).toBe(mid); - }); - - test('handles negative deltas symmetrically', async () => { - const scroller = new SmoothScroller(); - target.position = 500; - scroller.scrollBy(target, -300); - await flushFrames(120); - expect(target.position).toBeCloseTo(200, 1); - }); - - test('stops cleanly when target is past a clamping boundary', async () => { - const max = 100; - let position = 0; - const clampingTarget: SmoothScrollTarget = { - get position() { - return position; - }, - set position(value: number) { - position = Math.max(0, Math.min(max, value)); - }, - }; - - const scroller = new SmoothScroller(); - scroller.scrollBy(clampingTarget, 5000); - - await flushFrames(200); - - expect(position).toBe(max); - }); -}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 6d0ba649..59418d56 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -308,9 +308,7 @@ const FoliateViewer: React.FC<{ detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey)); detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey)); detail.doc.addEventListener('click', handleClick.bind(null, bookKey, doubleClickDisabled)); - // passive: false so handleWheel can preventDefault for mouse-wheel - // events and replace the native jerky scroll with a smooth animation. - detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey), { passive: false }); + detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey)); detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey)); detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey)); detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey)); diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index 0a7a77c4..2f77a546 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -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(null); - const smoothScrollTargetRef = useRef({ - 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, @@ -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; diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 3d38e738..207a33af 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -1,6 +1,5 @@ import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants'; import { eventDispatcher } from '@/utils/event'; -import { isLikelyMouseWheel } from './smoothWheelScroll'; let lastClickTime = 0; let longHoldTimeout: ReturnType | null = null; @@ -132,13 +131,6 @@ export const handleMouseup = (bookKey: string, event: MouseEvent) => { }; export const handleWheel = (bookKey: string, event: WheelEvent) => { - const isMouseWheel = isLikelyMouseWheel(event); - // Suppress the browser's native wheel scroll only for mouse-wheel-shaped - // events. Trackpad / high-resolution input is already pixel-precise, so - // we let it through to keep the existing momentum and 2-axis behaviour. - if (isMouseWheel) { - event.preventDefault(); - } window.postMessage( { type: 'iframe-wheel', @@ -147,7 +139,6 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => { deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ, - isMouseWheel, screenX: event.screenX, screenY: event.screenY, clientX: event.clientX, diff --git a/apps/readest-app/src/app/reader/utils/smoothWheelScroll.ts b/apps/readest-app/src/app/reader/utils/smoothWheelScroll.ts deleted file mode 100644 index cc9f28f6..00000000 --- a/apps/readest-app/src/app/reader/utils/smoothWheelScroll.ts +++ /dev/null @@ -1,102 +0,0 @@ -// A WheelEvent-like shape that also accepts the postMessage payload we forward -// from inside the iframe (which is a plain object, not a real WheelEvent). -export interface WheelEventLike { - deltaMode: number; - deltaX: number; - deltaY: number; -} - -const WHEEL_DELTA_THRESHOLD = 50; - -// Mouse wheels typically deliver a single large, quantised delta per notch -// (often a multiple of 100 or 120, after Chromium scales the legacy Win32 -// WHEEL_DELTA constant). High-resolution trackpads and free-spin wheels -// instead emit a stream of small, non-quantised deltas — usually with a -// non-zero deltaX from 2-axis movement and momentum tail. We classify on -// the strongest single-event signals so behaviour is predictable from the -// first notch. -export const isLikelyMouseWheel = (event: WheelEventLike): boolean => { - if (event.deltaMode === 1) return true; - if (event.deltaY === 0) return false; - if (event.deltaX !== 0) return false; - return Math.abs(event.deltaY) >= WHEEL_DELTA_THRESHOLD; -}; - -export interface SmoothScrollTarget { - get position(): number; - set position(value: number); -} - -// rAF-driven exponential lerp toward an accumulating target. New deltas -// extend the target; the animation eases out without snapping back. Uses -// performance.now() so frame-pacing scales correctly on high-refresh -// displays (the original Windows wheel jerk on 144Hz monitors comes from -// the browser delivering one ~100px jump every ~50ms with no interpolation -// between frames). -export class SmoothScroller { - private target = 0; - private animating = false; - private rafId = 0; - private lastFrameTime = 0; - // Per-millisecond decay constant: the fraction of remaining distance - // consumed each ms. 0.012 ≈ 6ms half-life — fast enough that wheel input - // still feels responsive, slow enough to mask one-notch jumps as motion. - private readonly decayPerMs: number; - private readonly minStep: number; - - constructor(decayPerMs = 0.012, minStep = 0.5) { - this.decayPerMs = decayPerMs; - this.minStep = minStep; - } - - scrollBy(target: SmoothScrollTarget, delta: number): void { - if (delta === 0) return; - const current = target.position; - if (!this.animating) { - this.target = current + delta; - } else { - this.target += delta; - } - this.start(target); - } - - cancel(): void { - if (this.rafId) cancelAnimationFrame(this.rafId); - this.rafId = 0; - this.animating = false; - } - - private start(target: SmoothScrollTarget): void { - if (this.animating) return; - this.animating = true; - this.lastFrameTime = performance.now(); - const tick = () => { - const now = performance.now(); - const dt = Math.min(64, now - this.lastFrameTime); - this.lastFrameTime = now; - const current = target.position; - const remaining = this.target - current; - if (Math.abs(remaining) < this.minStep) { - target.position = this.target; - this.animating = false; - this.rafId = 0; - return; - } - // Frame-rate-independent exponential decay: at 60Hz with decayPerMs - // 0.012 this lerps ~18% per frame, comparable to native momentum. - const factor = 1 - Math.pow(1 - this.decayPerMs, dt); - target.position = current + remaining * factor; - // Re-read after writing: scrollable elements clamp to [0, max], so a - // target past the boundary would otherwise loop forever. If we made - // no progress this frame, retarget to the clamped position and stop. - if (Math.abs(target.position - current) < 0.05) { - this.target = target.position; - this.animating = false; - this.rafId = 0; - return; - } - this.rafId = requestAnimationFrame(tick); - }; - this.rafId = requestAnimationFrame(tick); - } -}