From e18bfd681025576c818d5a41edb17e4d1380c695 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 27 Apr 2026 20:58:43 +0800 Subject: [PATCH] fix(reader): smooth out mouse wheel scrolling in scroll mode, closes #3966 (#3974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser delivers one large quantised delta per wheel notch, which Chromium scrolls without interpolation — producing the jerky one-step motion reported on Windows. Detect mouse-wheel-shaped events inside the iframe (line-mode, or single-axis with |deltaY| ≥ 50), suppress the native scroll, and replay the delta as an rAF exponential lerp on the renderer's container. Trackpad / high-resolution input is left to native scrolling so its momentum and 2-axis behaviour are preserved. --- .../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, 313 insertions(+), 17 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts create 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 new file mode 100644 index 00000000..1f788f41 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/utils/smoothWheelScroll.test.ts @@ -0,0 +1,151 @@ +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 e3e37e99..4708a57d 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -304,7 +304,9 @@ 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)); - detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey)); + // 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('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 26ca9abf..dd84aeae 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 } from 'react'; +import { useEffect, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { FoliateView } from '@/types/view'; import { ViewSettings } from '@/types/book'; @@ -9,6 +9,7 @@ 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'; @@ -110,6 +111,16 @@ 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, @@ -188,22 +199,37 @@ export const usePagination = ( viewPagination(viewRef.current, viewSettings, side); } } - } 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'); + } 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'); + } } + // 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(); @@ -247,6 +273,12 @@ 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 207a33af..3d38e738 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -1,5 +1,6 @@ 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; @@ -131,6 +132,13 @@ 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', @@ -139,6 +147,7 @@ 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 new file mode 100644 index 00000000..cc9f28f6 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/smoothWheelScroll.ts @@ -0,0 +1,102 @@ +// 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); + } +}