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:
@@ -0,0 +1,90 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { createWheelGestureDetector, WheelSample } from '@/app/reader/utils/wheelGesture';
|
||||
|
||||
const sample = (over: Partial<WheelSample> & Pick<WheelSample, 'timeStamp'>): WheelSample => ({
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
deltaMode: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('createWheelGestureDetector', () => {
|
||||
test('ignores a single tiny horizontal touch below the threshold', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30 });
|
||||
expect(detector.feed(sample({ deltaX: 4, timeStamp: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores sporadic tiny touches spaced beyond the idle gap (Magic Mouse brush)', () => {
|
||||
// Each light brush emits a tiny delta; spaced apart they must never
|
||||
// accumulate into a page turn — this is the accidental-flip bug.
|
||||
const detector = createWheelGestureDetector({ threshold: 30, idleResetMs: 200 });
|
||||
let t = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
t += 500; // > idle gap, so accumulators reset between touches
|
||||
expect(detector.feed(sample({ deltaX: 8, timeStamp: t }))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('flips exactly once when a deliberate horizontal swipe crosses the threshold', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30, idleResetMs: 200 });
|
||||
expect(detector.feed(sample({ deltaX: 12, timeStamp: 0 }))).toBeNull();
|
||||
expect(detector.feed(sample({ deltaX: 12, timeStamp: 16 }))).toBeNull();
|
||||
const flip = detector.feed(sample({ deltaX: 12, timeStamp: 32 }));
|
||||
expect(flip).not.toBeNull();
|
||||
expect(flip!.deltaX).toBeGreaterThan(0);
|
||||
expect(flip!.deltaY).toBe(0);
|
||||
});
|
||||
|
||||
test('swallows the inertial momentum tail after a flip', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30, idleResetMs: 200 });
|
||||
expect(detector.feed(sample({ deltaX: 40, timeStamp: 0 }))).not.toBeNull();
|
||||
// Momentum tail: continuous decaying events within the idle gap.
|
||||
let t = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
t += 16;
|
||||
expect(detector.feed(sample({ deltaX: 35, timeStamp: t }))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('starts a new gesture after the wheel goes idle', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30, idleResetMs: 200 });
|
||||
expect(detector.feed(sample({ deltaX: 40, timeStamp: 0 }))).not.toBeNull();
|
||||
// Idle gap elapses, then a fresh deliberate swipe.
|
||||
expect(detector.feed(sample({ deltaX: 40, timeStamp: 1000 }))).not.toBeNull();
|
||||
});
|
||||
|
||||
test('resolves to the dominant axis, ignoring cross-axis noise', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30 });
|
||||
// Large horizontal travel with small vertical jitter.
|
||||
detector.feed(sample({ deltaX: 20, deltaY: 3, timeStamp: 0 }));
|
||||
const flip = detector.feed(sample({ deltaX: 20, deltaY: -2, timeStamp: 16 }));
|
||||
expect(flip).not.toBeNull();
|
||||
expect(flip!.deltaY).toBe(0);
|
||||
expect(flip!.deltaX).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('preserves direction sign for negative (left/up) swipes', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30 });
|
||||
detector.feed(sample({ deltaY: -16, timeStamp: 0 }));
|
||||
const flip = detector.feed(sample({ deltaY: -16, timeStamp: 16 }));
|
||||
expect(flip).not.toBeNull();
|
||||
expect(flip!.deltaY).toBeLessThan(0);
|
||||
expect(flip!.deltaX).toBe(0);
|
||||
});
|
||||
|
||||
test('normalizes line-mode deltas so one wheel notch flips a page', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30, lineHeight: 40 });
|
||||
// A single line-mode notch (3 lines) normalizes to 120px > threshold.
|
||||
const flip = detector.feed(sample({ deltaY: 3, deltaMode: 1, timeStamp: 0 }));
|
||||
expect(flip).not.toBeNull();
|
||||
expect(flip!.deltaY).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('reset() clears accumulated travel', () => {
|
||||
const detector = createWheelGestureDetector({ threshold: 30 });
|
||||
detector.feed(sample({ deltaX: 20, timeStamp: 0 }));
|
||||
detector.reset();
|
||||
// After reset the earlier 20px is forgotten, so 20px more is still short.
|
||||
expect(detector.feed(sample({ deltaX: 20, timeStamp: 16 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, act } from '@testing-library/react';
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/store/readerStore', () => {
|
||||
return {
|
||||
@@ -19,26 +19,21 @@ vi.mock('@/utils/event', () => ({
|
||||
|
||||
import { useMouseEvent } from '@/app/reader/hooks/useIframeEvents';
|
||||
|
||||
function dispatchWheelMessage(bookKey: string) {
|
||||
function dispatchWheelMessage(bookKey: string, deltaY = 100) {
|
||||
// useMouseEvent listens on `message`, not `window.postMessage` directly,
|
||||
// so we dispatch a MessageEvent manually for synchronous delivery.
|
||||
const event = new MessageEvent('message', {
|
||||
data: { bookKey, type: 'iframe-wheel', deltaY: 100, ctrlKey: false },
|
||||
data: { bookKey, type: 'iframe-wheel', deltaY, deltaX: 0, deltaMode: 0, ctrlKey: false },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe('useMouseEvent debounce ref', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe('useMouseEvent wheel handling', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('debounced wheel handler dispatches to the latest handlePageFlip after re-render', () => {
|
||||
test('wheel flip dispatches to the latest handlePageFlip after re-render', () => {
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
|
||||
@@ -51,16 +46,44 @@ describe('useMouseEvent debounce ref', () => {
|
||||
}
|
||||
|
||||
const { rerender } = render(<Wrapper handler={fn1} />);
|
||||
// Re-render with a new handler reference. The debounced wheel wrapper
|
||||
// should pick up the latest one rather than holding onto fn1 forever.
|
||||
// Re-render with a new handler reference. The wheel flip path should
|
||||
// pick up the latest one rather than holding onto fn1 forever.
|
||||
rerender(<Wrapper handler={fn2} />);
|
||||
|
||||
dispatchWheelMessage('book-1');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150); // exceed the 100ms debounce
|
||||
});
|
||||
|
||||
expect(fn1).not.toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a single deliberate wheel notch flips exactly one page', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
useMouseEvent('book-1', handler as unknown as Parameters<typeof useMouseEvent>[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(<Wrapper />);
|
||||
dispatchWheelMessage('book-1', 120);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('tiny low-magnitude wheel events below the threshold do not flip', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
useMouseEvent('book-1', handler as unknown as Parameters<typeof useMouseEvent>[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(<Wrapper />);
|
||||
// A Magic Mouse light brush emits a flurry of tiny deltas; on their own
|
||||
// they must not turn a page.
|
||||
dispatchWheelMessage('book-1', 3);
|
||||
dispatchWheelMessage('book-1', 4);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user