forked from akai/readest
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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user