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:
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user