diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 7fe5d7f7..9b174bde 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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(null); const [selectedTableHtml, setSelectedTableHtml] = useState(null); diff --git a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx index ef0e552b..8a3df00d 100644 --- a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx +++ b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx @@ -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 = ({ } }, [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 { diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index 09c52bec..d771f72e 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -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(null); const touchStartTimeRef = useRef(null); const touchEndTimeRef = useRef(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) => { 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) => { @@ -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; }; diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index b4436f4c..bf4f1fd7 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -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, }; diff --git a/apps/readest-app/src/app/reader/hooks/useTouchInterceptor.ts b/apps/readest-app/src/app/reader/hooks/useTouchInterceptor.ts new file mode 100644 index 00000000..b9ae6ad1 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useTouchInterceptor.ts @@ -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(); + +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]); +};