From 745f28f346768363eb0fb13a1c53adce018fe97a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 3 Jul 2026 23:39:06 +0900 Subject: [PATCH] fix(reader): distinguish two-finger scroll from pinch-zoom on touchscreens (#4858) (#4912) On touchscreen laptops (e.g. Surface), scrolling a fixed-layout book webtoon-style with two fingers moving the same direction accidentally triggered pinch-zoom. The old code committed to a pinch on the first two-finger touch and applied the raw distance ratio from the first move, so a slightly non-parallel scroll drifted the finger spacing and zoomed. Defer the decision with a pending state: on two fingers, compare the change in finger separation against the midpoint travel. A pinch changes separation while the midpoint stays put; a scroll moves the midpoint while separation barely shifts. Zoom only engages once separation change crosses a 24px deadzone and outweighs the pan distance; a 12px pan locks the gesture as a scroll and lets the page scroll natively. On pinch confirm, re-baseline the distance so zoom starts at 1x with no snap. Co-authored-by: Claude Fable 5 --- .../__tests__/hooks/useTouchEvent.test.tsx | 115 ++++++++++++++++++ .../src/app/reader/hooks/useIframeEvents.ts | 62 +++++++++- 2 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx diff --git a/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx b/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx new file mode 100644 index 00000000..4bd273e0 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx @@ -0,0 +1,115 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +// Shared mock state so each test can configure the stores/renderer and inspect +// the calls the hook makes back into them. +const mocks = vi.hoisted(() => { + return { + hoveredBookKey: null as string | null, + setHoveredBookKey: vi.fn(), + getViewSettings: vi.fn(), + getView: vi.fn(), + getBookData: vi.fn(), + dispatch: vi.fn(), + }; +}); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + hoveredBookKey: mocks.hoveredBookKey, + setHoveredBookKey: mocks.setHoveredBookKey, + getViewSettings: mocks.getViewSettings, + getView: mocks.getView, + }), +})); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData: mocks.getBookData }), +})); + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { dispatch: mocks.dispatch }, +})); + +import { useTouchEvent } from '@/app/reader/hooks/useIframeEvents'; + +type Touch = { clientX: number; clientY: number; screenX: number; screenY: number }; +const touch = (screenX: number, screenY: number): Touch => ({ + clientX: screenX, + clientY: screenY, + screenX, + screenY, +}); +const touchEvent = (touches: Touch[], timeStamp = 0) => ({ timeStamp, targetTouches: touches }); + +type Handlers = ReturnType; + +const renderTouchHook = () => { + const ref: { current: Handlers | null } = { current: null }; + function Wrapper() { + ref.current = useTouchEvent('book-1'); + return null; + } + render(); + return ref as { current: Handlers }; +}; + +describe('useTouchEvent pinch vs two-finger scroll', () => { + let pinchZoom: ReturnType; + let pinchEnd: ReturnType; + + beforeEach(() => { + pinchZoom = vi.fn(); + pinchEnd = vi.fn(); + mocks.hoveredBookKey = null; + mocks.getBookData.mockReturnValue({ isFixedLayout: true }); + mocks.getViewSettings.mockReturnValue({ zoomLevel: 100, scrolled: true, vertical: false }); + mocks.getView.mockReturnValue({ renderer: { pinchZoom, pinchEnd } }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('two fingers moving in the same direction (scroll) do not zoom', () => { + const h = renderTouchHook(); + // Two fingers land ~100px apart. + h.current.onTouchStart(touchEvent([touch(100, 300), touch(200, 300)], 0)); + // Both fingers travel ~80px upward. Human scroll is not perfectly parallel, + // so the finger spacing drifts from 100px to 120px — enough that a naive + // ratio (currentDist/initialDist = 1.2) would zoom to 120%. + h.current.onTouchMove(touchEvent([touch(95, 220), touch(215, 220)], 16)); + h.current.onTouchEnd(touchEvent([], 32)); + + expect(pinchZoom).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalledWith('pinch-zoom', expect.anything()); + }); + + test('two fingers moving in opposite directions (pinch) zoom in', () => { + const h = renderTouchHook(); + h.current.onTouchStart(touchEvent([touch(150, 300), touch(250, 300)], 0)); + // Spread apart: midpoint stays fixed, separation grows 100 -> 160 -> 200. + h.current.onTouchMove(touchEvent([touch(120, 300), touch(280, 300)], 16)); + h.current.onTouchMove(touchEvent([touch(100, 300), touch(300, 300)], 32)); + h.current.onTouchEnd(touchEvent([], 48)); + + expect(pinchZoom).toHaveBeenCalled(); + expect(pinchEnd).toHaveBeenCalled(); + const dispatched = mocks.dispatch.mock.calls.find((c) => c[0] === 'pinch-zoom'); + expect(dispatched).toBeTruthy(); + // Zoomed in beyond the starting 100%. + expect(dispatched![1].zoomLevel).toBeGreaterThan(100); + }); + + test('tiny finger jitter below the deadzone does not zoom', () => { + const h = renderTouchHook(); + h.current.onTouchStart(touchEvent([touch(150, 300), touch(250, 300)], 0)); + // Separation wobbles by only a few px — noise, not intent. + h.current.onTouchMove(touchEvent([touch(148, 300), touch(253, 300)], 16)); + h.current.onTouchEnd(touchEvent([], 32)); + + expect(pinchZoom).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalledWith('pinch-zoom', expect.anything()); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index 8c32f07f..8c088af8 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -117,6 +117,16 @@ interface IframeTouchEvent { targetTouches: IframeTouch[]; } +// A two-finger gesture only becomes a pinch once the fingers' separation +// changes by at least this many pixels AND that change outweighs how far the +// pair has travelled together. On touchscreen laptops (e.g. Surface) users +// scroll with two fingers moving the same direction, which nudges the spacing +// a little; the deadzone keeps that from being read as an accidental zoom. +const PINCH_ACTIVATION_THRESHOLD = 24; +// Once the pair has translated this far together we lock the gesture as a +// two-finger scroll and stop looking for a pinch for the rest of it. +const TWO_FINGER_PAN_THRESHOLD = 12; + export const useTouchEvent = (bookKey: string) => { const { getBookData } = useBookDataStore(); const { hoveredBookKey, setHoveredBookKey, getViewSettings, getView } = useReaderStore(); @@ -126,7 +136,13 @@ export const useTouchEvent = (bookKey: string) => { const touchStartTimeRef = useRef(null); const touchEndTimeRef = useRef(null); const touchConsumedRef = useRef(false); + // Two fingers on a fixed-layout book start in a "pending" state: we wait to + // see whether they spread/converge (pinch) or slide together (scroll) before + // committing. isPinchingRef only flips true once a pinch is confirmed. + const pinchPendingRef = useRef(false); const isPinchingRef = useRef(false); + const initialTouch0Ref = useRef(null); + const initialTouch1Ref = useRef(null); const initialPinchDistRef = useRef(0); const initialZoomRef = useRef(100); const lastPinchRatioRef = useRef(1); @@ -161,7 +177,10 @@ export const useTouchEvent = (bookKey: string) => { if (t0 && t1) { const bookData = getBookData(bookKey); if (bookData?.isFixedLayout) { - isPinchingRef.current = true; + pinchPendingRef.current = true; + isPinchingRef.current = false; + initialTouch0Ref.current = t0; + initialTouch1Ref.current = t1; initialPinchDistRef.current = getTouchDistance(t0, t1); initialZoomRef.current = getViewSettings(bookKey)?.zoomLevel ?? 100; lastPinchRatioRef.current = 1; @@ -187,7 +206,34 @@ export const useTouchEvent = (bookKey: string) => { const onTouchMove = (e: IframeTouchEvent | React.TouchEvent) => { const t0 = e.targetTouches[0] as IframeTouch | undefined; const t1 = e.targetTouches[1] as IframeTouch | undefined; - if (isPinchingRef.current && t0 && t1) { + if ((pinchPendingRef.current || isPinchingRef.current) && t0 && t1) { + if (pinchPendingRef.current) { + const init0 = initialTouch0Ref.current; + const init1 = initialTouch1Ref.current; + if (!init0 || !init1) return; + const currentDist = getTouchDistance(t0, t1); + const separationDelta = Math.abs(currentDist - initialPinchDistRef.current); + // How far the finger pair has slid together (midpoint travel). A pinch + // keeps the midpoint roughly still while the separation changes; a + // two-finger scroll moves the midpoint while the separation barely + // shifts. + const panX = (t0.screenX - init0.screenX + (t1.screenX - init1.screenX)) / 2; + const panY = (t0.screenY - init0.screenY + (t1.screenY - init1.screenY)) / 2; + const panDist = Math.sqrt(panX * panX + panY * panY); + if (separationDelta >= PINCH_ACTIVATION_THRESHOLD && separationDelta > panDist) { + // Confirmed pinch. Re-baseline the distance so the zoom starts at 1x + // from here — the deadzone travel is absorbed rather than snapping. + pinchPendingRef.current = false; + isPinchingRef.current = true; + initialPinchDistRef.current = currentDist; + } else if (panDist >= TWO_FINGER_PAN_THRESHOLD && panDist >= separationDelta) { + // Two-finger scroll — bow out and let the page scroll natively. + pinchPendingRef.current = false; + return; + } else { + return; // not enough movement to decide yet + } + } const currentDist = getTouchDistance(t0, t1); if (initialPinchDistRef.current > 0) { const ratio = currentDist / initialPinchDistRef.current; @@ -232,13 +278,19 @@ export const useTouchEvent = (bookKey: string) => { }; const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent) => { - if (isPinchingRef.current) { + if (isPinchingRef.current || pinchPendingRef.current) { const t0 = e.targetTouches[0] as IframeTouch | undefined; const t1 = e.targetTouches[1] as IframeTouch | undefined; - if (t0 && t1) return; // still pinching with 2+ fingers + if (t0 && t1) return; // still two fingers down + const wasPinching = isPinchingRef.current; isPinchingRef.current = false; + pinchPendingRef.current = false; + initialTouch0Ref.current = null; + initialTouch1Ref.current = null; + // Only commit a zoom if a pinch was actually confirmed. A gesture that + // stayed pending (jitter) or resolved to a scroll leaves zoom untouched. const renderer = getView(bookKey)?.renderer; - if (renderer && initialPinchDistRef.current > 0) { + if (wasPinching && renderer && initialPinchDistRef.current > 0) { renderer.pinchEnd?.(); const newZoom = Math.round(initialZoomRef.current * lastPinchRatioRef.current); const clampedZoom = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, newZoom));