fix(reader): smooth out mouse wheel scrolling in scroll mode, closes #3966 (#3974)

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.
This commit is contained in:
Huang Xin
2026-04-27 20:58:43 +08:00
committed by GitHub
parent 6d798542f6
commit e18bfd6810
5 changed files with 313 additions and 17 deletions
@@ -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<number, FrameRequestCallback>();
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);
});
});
@@ -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));
@@ -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<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>,
@@ -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;
@@ -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<typeof setTimeout> | 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,
@@ -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);
}
}