From ad1c2d6bb05a0537a8ac8fffdaf11e99fa8ab2ed Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 17 May 2026 03:30:53 +0800 Subject: [PATCH] fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/reader/utils/wheelGesture.test.ts | 90 +++++++++++++++ .../__tests__/hooks/useIframeEvents.test.tsx | 55 ++++++--- .../src/app/reader/hooks/useIframeEvents.ts | 46 +++++--- .../src/app/reader/utils/wheelGesture.ts | 109 ++++++++++++++++++ 4 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/reader/utils/wheelGesture.test.ts create mode 100644 apps/readest-app/src/app/reader/utils/wheelGesture.ts diff --git a/apps/readest-app/src/__tests__/app/reader/utils/wheelGesture.test.ts b/apps/readest-app/src/__tests__/app/reader/utils/wheelGesture.test.ts new file mode 100644 index 00000000..b9ec6d4b --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/utils/wheelGesture.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'vitest'; +import { createWheelGestureDetector, WheelSample } from '@/app/reader/utils/wheelGesture'; + +const sample = (over: Partial & Pick): 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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useIframeEvents.test.tsx b/apps/readest-app/src/__tests__/hooks/useIframeEvents.test.tsx index 9cf91f43..2c4e52ad 100644 --- a/apps/readest-app/src/__tests__/hooks/useIframeEvents.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useIframeEvents.test.tsx @@ -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(); - // 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(); 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[1]); + return null; + } + + render(); + 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[1]); + return null; + } + + render(); + // 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(); + }); }); diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index 748a9662..fc64cd73 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -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) => 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) => - 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 | null>(null); + if (!wheelDetectorRef.current) { + wheelDetectorRef.current = createWheelGestureDetector(); + } const handleMouseEvent = (msg: MessageEvent | React.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); diff --git a/apps/readest-app/src/app/reader/utils/wheelGesture.ts b/apps/readest-app/src/app/reader/utils/wheelGesture.ts new file mode 100644 index 00000000..f35f5f1a --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/wheelGesture.ts @@ -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;