forked from akai/readest
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:
@@ -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