fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)

A touch-surface mouse like the Magic Mouse emits a flood of tiny, low-
magnitude wheel events — plus an inertial momentum tail — for a single
physical gesture, and even a light brush of the surface produces spurious
deltas. The previous 100ms trailing debounce collapsed bursts but did not
filter by magnitude, so isolated micro-touches and the momentum tail each
turned a page, cascading into continuous accidental page turns in
paginated mode.

Add a wheel gesture detector that accumulates normalized wheel travel and
only flips once it crosses a deliberate-intent threshold, then swallows the
rest of the stream (the momentum tail) until the wheel goes idle — so one
physical gesture flips exactly one page, mirroring native readers.

Closes #4117

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-17 03:30:53 +08:00
committed by GitHub
parent f2a2d96938
commit ad1c2d6bb0
4 changed files with 267 additions and 33 deletions
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '@/services/constants';
import { createWheelGestureDetector } from '@/app/reader/utils/wheelGesture';
import { dispatchTouchInterceptors, TouchDetail } from './useTouchInterceptor';
export const useMouseEvent = (
@@ -11,36 +11,48 @@ export const useMouseEvent = (
handlePageFlip: (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => void,
) => {
const { hoveredBookKey } = useReaderStore();
// Keep the latest handlePageFlip in a ref so the debounced wrapper (created
// once via useMemo) always invokes the most recent closure. Without this
// ref, the empty-deps useMemo would freeze the first-render handler and any
// state captured in subsequent re-renders would be invisible to wheel-driven
// page flips.
// Keep the latest handlePageFlip in a ref so the wheel-driven flip path
// always invokes the most recent closure, independent of when listeners
// were registered.
const handlePageFlipRef = useRef(handlePageFlip);
useEffect(() => {
handlePageFlipRef.current = handlePageFlip;
}, [handlePageFlip]);
const debounceFlip = useMemo(
() =>
debounce(
(msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) =>
handlePageFlipRef.current(msg),
100,
),
[],
);
// Filters the raw wheel stream so a touch-surface mouse (e.g. Magic Mouse)
// — which emits a flood of tiny events plus an inertial momentum tail for
// one physical gesture — flips exactly one page instead of cascading
// through several. See wheelGesture.ts.
const wheelDetectorRef = useRef<ReturnType<typeof createWheelGestureDetector> | null>(null);
if (!wheelDetectorRef.current) {
wheelDetectorRef.current = createWheelGestureDetector();
}
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
if (msg.data.type === 'iframe-wheel') {
if (msg.data.ctrlKey) {
// Pinch/ctrl-wheel zoom is not a page-turn gesture — drop any
// travel accumulated so far so it can't bleed into a later flip.
wheelDetectorRef.current!.reset();
if (msg.data.deltaY > 0) {
eventDispatcher.dispatch('zoom-out', { factor: Math.abs(msg.data.deltaY) / 100 });
} else if (msg.data.deltaY < 0) {
eventDispatcher.dispatch('zoom-in', { factor: Math.abs(msg.data.deltaY) / 100 });
}
} else {
debounceFlip(msg);
const flip = wheelDetectorRef.current!.feed({
deltaX: msg.data.deltaX ?? 0,
deltaY: msg.data.deltaY ?? 0,
deltaMode: msg.data.deltaMode ?? 0,
timeStamp: Date.now(),
});
if (flip) {
handlePageFlipRef.current(
new MessageEvent('message', {
data: { ...msg.data, deltaX: flip.deltaX, deltaY: flip.deltaY },
}),
);
}
}
} else {
handlePageFlip(msg);
@@ -0,0 +1,109 @@
// Wheel-to-page-flip gesture detection.
//
// In paginated mode a horizontal/vertical wheel event flips the page. Touch
// surfaces like the Magic Mouse emit a long stream of tiny, low-magnitude
// wheel events for a single physical gesture (plus an inertial "momentum"
// tail), and even a light brush of the surface produces spurious deltas.
// Without filtering, every one of those events turns a page, so a single
// touch cascades into several accidental page turns.
//
// This detector mirrors what native readers (e.g. Apple Books) do: it
// accumulates wheel travel, only flips once the accumulated distance crosses
// a deliberate-intent threshold, and then swallows the rest of the stream
// (the momentum tail) until the wheel goes idle — so one gesture flips
// exactly one page.
export interface WheelSample {
deltaX: number;
deltaY: number;
/** WheelEvent.deltaMode: 0 = pixel, 1 = line, 2 = page. */
deltaMode: number;
/** Monotonic timestamp in milliseconds. */
timeStamp: number;
}
export interface WheelFlipResult {
deltaX: number;
deltaY: number;
}
export interface WheelGestureOptions {
/** Accumulated normalized travel (px) required before a page turn fires. */
threshold?: number;
/** Idle gap (ms) after which a gesture — including its momentum tail — is
* considered finished and the accumulators reset. */
idleResetMs?: number;
/** Pixels per line, used to normalize line-mode (deltaMode 1) deltas. */
lineHeight?: number;
/** Pixels per page, used to normalize page-mode (deltaMode 2) deltas. */
pageHeight?: number;
}
const DEFAULT_THRESHOLD = 30;
const DEFAULT_IDLE_RESET_MS = 200;
const DEFAULT_LINE_HEIGHT = 40;
const DEFAULT_PAGE_HEIGHT = 800;
export const createWheelGestureDetector = (options: WheelGestureOptions = {}) => {
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
const idleResetMs = options.idleResetMs ?? DEFAULT_IDLE_RESET_MS;
const lineHeight = options.lineHeight ?? DEFAULT_LINE_HEIGHT;
const pageHeight = options.pageHeight ?? DEFAULT_PAGE_HEIGHT;
let accumX = 0;
let accumY = 0;
let lastTime = -Infinity;
// True once a flip has fired for the current gesture: the remaining events
// are the momentum tail and must be ignored until the wheel goes idle.
let flipped = false;
const normalize = (delta: number, mode: number) =>
mode === 1 ? delta * lineHeight : mode === 2 ? delta * pageHeight : delta;
/**
* Feed one wheel sample. Returns the axis-resolved delta to flip by, or
* `null` when the sample should not (yet) trigger a page turn.
*/
const feed = (sample: WheelSample): WheelFlipResult | null => {
// A gap longer than idleResetMs means the previous gesture (including any
// inertial momentum tail) has ended — start a fresh gesture.
if (sample.timeStamp - lastTime > idleResetMs) {
accumX = 0;
accumY = 0;
flipped = false;
}
lastTime = sample.timeStamp;
// Already flipped for this gesture: swallow the momentum tail.
if (flipped) return null;
accumX += normalize(sample.deltaX, sample.deltaMode);
accumY += normalize(sample.deltaY, sample.deltaMode);
if (Math.abs(accumX) < threshold && Math.abs(accumY) < threshold) {
return null;
}
flipped = true;
// Resolve to the dominant axis so a tiny amount of cross-axis noise
// doesn't flip in the wrong direction.
const result: WheelFlipResult =
Math.abs(accumX) > Math.abs(accumY)
? { deltaX: accumX, deltaY: 0 }
: { deltaX: 0, deltaY: accumY };
accumX = 0;
accumY = 0;
return result;
};
const reset = () => {
accumX = 0;
accumY = 0;
lastTime = -Infinity;
flipped = false;
};
return { feed, reset };
};
export type WheelGestureDetector = ReturnType<typeof createWheelGestureDetector>;