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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user