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 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 23:39:06 +09:00
committed by GitHub
parent 2b524439bf
commit 745f28f346
2 changed files with 172 additions and 5 deletions
@@ -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<typeof useTouchEvent>;
const renderTouchHook = () => {
const ref: { current: Handlers | null } = { current: null };
function Wrapper() {
ref.current = useTouchEvent('book-1');
return null;
}
render(<Wrapper />);
return ref as { current: Handlers };
};
describe('useTouchEvent pinch vs two-finger scroll', () => {
let pinchZoom: ReturnType<typeof vi.fn>;
let pinchEnd: ReturnType<typeof vi.fn>;
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());
});
});
@@ -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<number | null>(null);
const touchEndTimeRef = useRef<number | null>(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<IframeTouch | null>(null);
const initialTouch1Ref = useRef<IframeTouch | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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));