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 {