refactor(reader): introduce priority-based touch interceptor for gesture handling (#3809)

Replace the inline touch-swipe event dispatching with a module-level
interceptor registry. This lets the reading ruler (priority 10) claim
drag gestures before the swipe-to-flip handler (priority 0), preventing
conflicts when dragging the ruler on touch devices.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-09 17:48:21 +08:00
committed by GitHub
parent 4abbc17f66
commit 1e259e87b2
5 changed files with 227 additions and 42 deletions
@@ -342,7 +342,7 @@ const FoliateViewer: React.FC<{
const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
@@ -8,6 +8,7 @@ import { saveViewSettings } from '@/helpers/settings';
import { READING_RULER_COLORS } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import { useTouchInterceptor } from '../hooks/useTouchInterceptor';
import {
calculateReadingRulerSize,
clampReadingRulerPosition,
@@ -305,6 +306,82 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
}
}, [containerSize.width, containerSize.height, isVertical, clampPosition, setRulerPosition]);
// Touch interceptor: allows dragging the ruler from anywhere on its body.
// The ruler body stays pointer-events-none so text selection works through it.
// Returning true consumes the gesture, preventing swipe/page-flip.
const isTouchDraggingRef = useRef(false);
const touchInRulerRef = useRef(false);
useTouchInterceptor(
`ruler-drag-${bookKey}`,
(bk, detail) => {
if (bk !== bookKey) return false;
if (detail.phase === 'start') {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return false;
const vx = detail.touch.screenX - (window.screenX || 0);
const vy = detail.touch.screenY - (window.screenY || 0);
const dim = isVertical ? rect.width : rect.height;
const center = (currentPositionRef.current / 100) * dim;
const half = rulerSize / 2;
const rel = isVertical ? vx - rect.left : vy - rect.top;
touchInRulerRef.current = rel >= center - half && rel <= center + half;
isTouchDraggingRef.current = false;
return false;
}
if (detail.phase === 'move') {
if (!touchInRulerRef.current) return false;
if (!isTouchDraggingRef.current) {
const dx = Math.abs(detail.deltaX);
const dy = Math.abs(detail.deltaY);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= 10) {
const isDragGesture = isVertical ? dx >= 3 * dy : dy >= 3 * dx;
if (isDragGesture) {
isTouchDraggingRef.current = true;
isDragging.current = true;
dragPointerOffsetRef.current = 0;
setShouldAnimate(false);
} else {
touchInRulerRef.current = false;
return false;
}
} else {
return false;
}
}
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return true;
const vx = detail.touch.screenX - (window.screenX || 0);
const vy = detail.touch.screenY - (window.screenY || 0);
const dim = isVertical ? rect.width : rect.height;
const rel = isVertical ? vx - rect.left : vy - rect.top;
const newPos = clampPosition((rel / dim) * 100, dim);
setCurrentPosition(newPos);
currentPositionRef.current = newPos;
return true;
}
if (detail.phase === 'end') {
const wasConsumed = isTouchDraggingRef.current;
if (wasConsumed) {
isDragging.current = false;
throttledSave(currentPositionRef.current);
}
touchInRulerRef.current = false;
isTouchDraggingRef.current = false;
return wasConsumed;
}
return false;
},
10, // higher priority than swipe-to-flip (0)
);
useEffect(() => {
const handleMove = (event: CustomEvent) => {
const detail = (event.detail ?? {}) as {
@@ -4,6 +4,7 @@ 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 { dispatchTouchInterceptors, TouchDetail } from './useTouchInterceptor';
export const useMouseEvent = (
bookKey: string,
@@ -83,7 +84,7 @@ interface IframeTouchEvent {
targetTouches: IframeTouch[];
}
export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent) => void) => {
export const useTouchEvent = (bookKey: string) => {
const { getBookData } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey, getViewSettings, getView } = useReaderStore();
@@ -91,6 +92,7 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
const touchEndRef = useRef<IframeTouch | null>(null);
const touchStartTimeRef = useRef<number | null>(null);
const touchEndTimeRef = useRef<number | null>(null);
const touchConsumedRef = useRef(false);
const isPinchingRef = useRef(false);
const initialPinchDistRef = useRef(0);
const initialZoomRef = useRef(100);
@@ -105,6 +107,21 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
return Math.sqrt(dx * dx + dy * dy);
};
const buildTouchDetail = (
phase: 'start' | 'move' | 'end',
touch: IframeTouch,
touchStart: IframeTouch,
startTime: number | null,
endTime: number | null,
): TouchDetail => ({
phase,
touch: { screenX: touch.screenX, screenY: touch.screenY },
touchStart: { screenX: touchStart.screenX, screenY: touchStart.screenY },
deltaX: touch.screenX - touchStart.screenX,
deltaY: touch.screenY - touchStart.screenY,
deltaT: endTime && startTime ? endTime - startTime : 0,
});
const onTouchStart = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
const t0 = e.targetTouches[0] as IframeTouch | undefined;
const t1 = e.targetTouches[1] as IframeTouch | undefined;
@@ -123,6 +140,15 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
if (!t0) return;
touchStartRef.current = t0;
touchStartTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
touchConsumedRef.current = false;
const detail = buildTouchDetail(
'start',
t0,
t0,
touchStartTimeRef.current,
touchStartTimeRef.current,
);
dispatchTouchInterceptors(bookKey, detail);
};
const onTouchMove = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
@@ -143,7 +169,19 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
if (touch) {
touchEndRef.current = touch;
touchEndTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
const detail = buildTouchDetail(
'move',
touch,
touchStartRef.current,
touchStartTimeRef.current,
touchEndTimeRef.current,
);
if (dispatchTouchInterceptors(bookKey, detail)) {
touchConsumedRef.current = true;
return;
}
}
if (touchConsumedRef.current) return;
const { current: touchStart } = touchStartRef;
const { current: touchEnd } = touchEndRef;
if (hoveredBookKey && touchEnd) {
@@ -185,28 +223,44 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
touchEndTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
}
const windowWidth = window.innerWidth;
const { current: touchStart } = touchStartRef;
const { current: touchEnd } = touchEndRef;
const { current: touchStartTime } = touchStartTimeRef;
const { current: touchEndTime } = touchEndTimeRef;
if (touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
// Dispatch end to interceptors, then check if the gesture was consumed
if (touchEnd && touchStart) {
const detail = buildTouchDetail(
'end',
touchEnd,
touchStart,
touchStartTimeRef.current,
touchEndTimeRef.current,
);
dispatchTouchInterceptors(bookKey, detail);
}
if (touchConsumedRef.current) {
touchConsumedRef.current = false;
touchStartRef.current = null;
touchEndRef.current = null;
return;
}
// Gesture was not consumed — handle hover bar toggle
if (touchEnd && touchStart) {
const windowWidth = window.innerWidth;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
const deltaT = touchEndTime && touchStartTime ? touchEndTime - touchStartTime : 0;
// also check for deltaX to prevent swipe page turn from triggering the toggle
if (
deltaY < -10 &&
Math.abs(deltaY) > Math.abs(deltaX) * 2 &&
Math.abs(deltaX) < windowWidth * 0.3
) {
// swipe up to toggle the header bar and the footer bar, only for horizontal page mode
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
if (
!viewSettings!.scrolled && // not scrolled
!viewSettings!.vertical && // not vertical
(!bookData.isFixedLayout || viewSettings.zoomLevel <= 100) // for fixed layout, not when zoomed in
!viewSettings!.scrolled &&
!viewSettings!.vertical &&
(!bookData.isFixedLayout || viewSettings.zoomLevel <= 100)
) {
setHoveredBookKey(hoveredBookKey ? null : bookKey);
}
@@ -215,21 +269,9 @@ export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent
setHoveredBookKey(null);
}
}
handlePageFlip(
new CustomEvent('touch-swipe', {
detail: {
deltaX,
deltaY,
deltaT,
startX: touchStart.screenX,
startY: touchStart.screenY,
endX: touchEnd.screenX,
endY: touchEnd.screenY,
},
}),
);
}
touchConsumedRef.current = false;
touchStartRef.current = null;
touchEndRef.current = null;
};
@@ -9,6 +9,7 @@ import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
import { getReadingRulerMoveDirection } from '../utils/readingRuler';
import { useTouchInterceptor } from './useTouchInterceptor';
export type ScrollSource = 'touch' | 'mouse';
@@ -227,21 +228,6 @@ export const usePagination = (
}
viewPagination(viewRef.current, viewSettings, 'down');
}
} else if (
msg.type === 'touch-swipe' &&
bookData.isFixedLayout &&
!viewSettings?.scrolled &&
!isPanningView(viewRef.current, viewSettings)
) {
const { deltaX, deltaY, deltaT } = msg.detail;
const vx = Math.abs(deltaX / deltaT);
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30 && vx > 0.2) {
if (deltaX > 0) {
viewPagination(viewRef.current, viewSettings, 'left');
} else {
viewPagination(viewRef.current, viewSettings, 'right');
}
}
}
} else {
if (msg.type === 'click') {
@@ -277,6 +263,28 @@ export const usePagination = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Touch swipe page flip for fixed-layout books — registered as a touch interceptor
// so it participates in the priority-based consumption chain.
useTouchInterceptor(
`swipe-flip-${bookKey}`,
(bk, detail) => {
if (bk !== bookKey || detail.phase !== 'end') return false;
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!bookData?.isFixedLayout || viewSettings?.scrolled) return false;
if (isPanningView(viewRef.current, viewSettings)) return false;
const { deltaX, deltaY, deltaT } = detail;
const vx = Math.abs(deltaX / (deltaT || 1));
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30 && vx > 0.2) {
viewPagination(viewRef.current, viewSettings, deltaX > 0 ? 'left' : 'right');
return true;
}
return false;
},
0,
);
return {
handlePageFlip,
};
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
export interface TouchDetail {
phase: 'start' | 'move' | 'end';
touch: { screenX: number; screenY: number };
touchStart: { screenX: number; screenY: number };
deltaX: number;
deltaY: number;
deltaT: number;
}
export type TouchInterceptorFn = (bookKey: string, detail: TouchDetail) => boolean;
interface TouchInterceptorEntry {
handler: TouchInterceptorFn;
priority: number;
}
// Module-level registry — interceptors are called in descending priority order.
// The first interceptor that returns true consumes the gesture.
const interceptors = new Map<string, TouchInterceptorEntry>();
export const registerTouchInterceptor = (
id: string,
handler: TouchInterceptorFn,
priority = 0,
): (() => void) => {
interceptors.set(id, { handler, priority });
return () => {
interceptors.delete(id);
};
};
export const dispatchTouchInterceptors = (bookKey: string, detail: TouchDetail): boolean => {
const sorted = [...interceptors.values()].sort((a, b) => b.priority - a.priority);
for (const { handler } of sorted) {
if (handler(bookKey, detail)) return true;
}
return false;
};
/**
* React hook for registering a touch interceptor with automatic cleanup.
* The handler ref is updated on every render so the interceptor always
* calls the latest closure without re-registering.
*/
export const useTouchInterceptor = (id: string, handler: TouchInterceptorFn, priority = 0) => {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
return registerTouchInterceptor(
id,
(bookKey, detail) => handlerRef.current(bookKey, detail),
priority,
);
}, [id, priority]);
};