import { useEffect } from 'react'; import { useEnv } from '@/context/EnvContext'; import { FoliateView } from '@/types/view'; import { ViewSettings } from '@/types/book'; import { useReaderStore } from '@/store/readerStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { useDeviceControlStore } from '@/store/deviceStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { eventDispatcher } from '@/utils/event'; import { resolvePageTurn, normalizeDomKeyEvent, KeyCandidate } from '@/utils/keybinding'; import { isTauriAppPlatform } from '@/services/environment'; import { tauriGetWindowLogicalPosition } from '@/utils/window'; import { getReadingRulerMoveDirection } from '../utils/readingRuler'; import { useTouchInterceptor } from './useTouchInterceptor'; export type ScrollSource = 'touch' | 'mouse'; type PaginationSide = 'left' | 'right' | 'up' | 'down'; type PaginationMode = 'pan' | 'page' | 'section'; const swapLeftRight = (side: PaginationSide) => { if (side === 'left') return 'right'; if (side === 'right') return 'left'; return side; }; const isPanningView = (view: FoliateView | null, viewSettings: ViewSettings | null | undefined) => { if (!view || !viewSettings) return false; return ( view.book.rendition?.layout === 'pre-paginated' && (viewSettings.zoomLevel > 100 || viewSettings.zoomMode !== 'fit-page') ); }; const hasHorizontalPanning = ( view: FoliateView | null, viewSettings: ViewSettings | null | undefined, ) => { if (!view || !viewSettings) return false; return isPanningView(view, viewSettings) && view.isOverflowX(); }; const hasVerticalPanning = ( view: FoliateView | null, viewSettings: ViewSettings | null | undefined, ) => { if (!view || !viewSettings) return false; return isPanningView(view, viewSettings) && view.isOverflowY(); }; export const viewPagination = ( view: FoliateView | null, viewSettings: ViewSettings | null | undefined, side: PaginationSide, mode: PaginationMode = 'page', panDistance: number = 50, ) => { if (!view || !viewSettings) return; const renderer = view.renderer; if (viewSettings.rtl) { side = swapLeftRight(side); } if (renderer.scrolled) { const { size } = renderer; const showHeader = viewSettings.showHeader; const showFooter = viewSettings.showFooter; const scrollingOverlap = viewSettings.scrollingOverlap; const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0); switch (mode) { case 'section': if (side === 'left' || side === 'up') { return view.renderer.prevSection?.(); } else { return view.renderer.nextSection?.(); } case 'pan': case 'page': default: return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance); } } else if (mode === 'pan' && isPanningView(view, viewSettings)) { if (hasHorizontalPanning(view, viewSettings) && (side === 'left' || side === 'right')) { return view.pan(side === 'left' ? -panDistance : panDistance, 0); } else if (hasVerticalPanning(view, viewSettings) && (side === 'up' || side === 'down')) { return view.pan(0, side === 'up' ? -panDistance : panDistance); } else { return side === 'left' || side === 'up' ? view.prev() : view.next(); } } else { switch (mode) { case 'section': if (side === 'left' || side === 'up') { return view.renderer.prevSection?.(); } else { return view.renderer.nextSection?.(); } case 'pan': case 'page': default: return side === 'left' || side === 'up' ? view.prev() : view.next(); } } }; export const usePagination = ( bookKey: string, viewRef: React.RefObject, containerRef: React.RefObject, ) => { const { appService } = useEnv(); const { getBookData } = useBookDataStore(); const { getViewSettings, getViewState } = useReaderStore(); const { hoveredBookKey, setHoveredBookKey } = useReaderStore(); const { acquireVolumeKeyInterception, releaseVolumeKeyInterception, acquirePageTurnerKeyInterception, releasePageTurnerKeyInterception, } = useDeviceControlStore(); // Reactive subscription: drives the effect dependency array below. The // handlers themselves re-read via getState() to avoid stale closures. const hardwarePageTurner = useSettingsStore((s) => s.settings.hardwarePageTurner); const handlePageFlip = async ( msg: MessageEvent | CustomEvent | React.MouseEvent, ) => { const viewState = getViewState(bookKey); const bookData = getBookData(bookKey); if (!viewState?.inited || !bookData) return; const dispatchReadingRulerMove = (side: PaginationSide) => { return eventDispatcher.dispatchSync('reading-ruler-move', { bookKey, direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir), }); }; if (msg instanceof MessageEvent) { if (msg.data && msg.data.bookKey === bookKey) { const viewSettings = getViewSettings(bookKey)!; if (msg.data.type === 'iframe-single-click') { const viewElement = containerRef.current; if (viewElement) { const { screenX } = msg.data; const viewRect = viewElement.getBoundingClientRect(); let windowStartX; // Currently for tauri APP the window.screenX is always 0 if (isTauriAppPlatform()) { if (appService?.isMobile) { windowStartX = 0; } else { const windowPosition = (await tauriGetWindowLogicalPosition()) as { x: number; y: number; }; windowStartX = windowPosition.x; } } else { windowStartX = window.screenX; } const viewStartX = windowStartX + viewRect.left; const viewCenterX = viewStartX + viewRect.width / 2; const consumed = eventDispatcher.dispatchSync('iframe-single-click'); if (!consumed) { const centerStartX = viewStartX + viewRect.width * 0.375; const centerEndX = viewStartX + viewRect.width * 0.625; if ( viewSettings.disableClick! || (screenX >= centerStartX && screenX <= centerEndX) ) { // toggle visibility of the header bar and the footer bar setHoveredBookKey(hoveredBookKey ? null : bookKey); return; } if (hoveredBookKey) { setHoveredBookKey(null); return; } const side: PaginationSide = screenX >= viewCenterX ? viewSettings.fullscreenClickArea ? 'down' : viewSettings.swapClickArea ? 'left' : 'right' : viewSettings.fullscreenClickArea ? 'down' : viewSettings.swapClickArea ? 'right' : 'left'; if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove(side)) { return; } viewPagination(viewRef.current, viewSettings, side); } } } else if ( msg.data.type === 'iframe-wheel' && !viewSettings.scrolled && !isPanningView(viewRef.current, viewSettings) ) { // The wheel event is handled by the iframe itself in scrolled mode. const { deltaY, deltaX } = msg.data; if (deltaY > 0) { viewPagination(viewRef.current, viewSettings, 'down'); } else if (deltaY < 0) { viewPagination(viewRef.current, viewSettings, 'up'); } else if (deltaX < 0) { viewPagination(viewRef.current, viewSettings, 'left'); } else if (deltaX > 0) { viewPagination(viewRef.current, viewSettings, 'right'); } } else if (msg.data.type === 'iframe-mouseup') { if (msg.data.button === 3) { viewRef.current?.history.back(); } else if (msg.data.button === 4) { viewRef.current?.history.forward(); } } } } else if (msg instanceof CustomEvent) { const viewSettings = getViewSettings(bookKey); if (msg.type === 'native-key-down' && viewSettings?.volumeKeysToFlip) { const { keyName } = msg.detail; setHoveredBookKey(''); if (keyName === 'VolumeUp') { if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('up')) { return; } viewPagination(viewRef.current, viewSettings, 'up'); } else if (keyName === 'VolumeDown') { if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('down')) { return; } viewPagination(viewRef.current, viewSettings, 'down'); } } } else { if (msg.type === 'click') { const { clientX } = msg; const width = window.innerWidth; const leftThreshold = width * 0.5; const rightThreshold = width * 0.5; const viewSettings = getViewSettings(bookKey); if (!viewSettings?.disableClick) { if (clientX < leftThreshold) { viewPagination(viewRef.current, viewSettings, 'left'); } else if (clientX > rightThreshold) { viewPagination(viewRef.current, viewSettings, 'right'); } } } } }; // Hardware page turner: media keys arrive via the `native-key-down` // event; D-pad / keyboard keys arrive either as a top-window `keydown` // or — when focus is inside a book iframe — as an `iframe-keydown` // postMessage (mirroring useShortcuts' unified window + iframe handling). // All resolve through the shared binding registry. Suppressed while the // toolbar is visible so D-pad keys keep driving toolbar spatial navigation. const handleHardwarePageTurn = (candidate: KeyCandidate): boolean => { const settings = useSettingsStore.getState().settings.hardwarePageTurner; if (!settings?.enabled) return false; if (useReaderStore.getState().hoveredBookKey) return false; // Only the active book (the one driving the sidebar) responds, so a // single key press doesn't flip every book open in a parallel view. if (useSidebarStore.getState().sideBarBookKey !== bookKey) return false; const viewState = getViewState(bookKey); if (!viewState?.inited) return false; const action = resolvePageTurn(settings, candidate); if (!action) return false; const viewSettings = getViewSettings(bookKey); const side = action === 'pagePrev' || action === 'sectionPrev' ? 'up' : 'down'; const mode = action === 'sectionPrev' || action === 'sectionNext' ? 'section' : 'page'; setHoveredBookKey(''); if ( mode === 'page' && viewSettings?.readingRulerEnabled && eventDispatcher.dispatchSync('reading-ruler-move', { bookKey, direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir), }) ) { return true; } viewPagination(viewRef.current, viewSettings, side, mode); return true; }; const handleHardwareNativeKey = (msg: CustomEvent) => { const keyName = msg.detail?.keyName; if (typeof keyName !== 'string') return; handleHardwarePageTurn({ source: 'native', id: keyName }); }; const handleHardwareDomKey = (event: KeyboardEvent | MessageEvent) => { let candidate: KeyCandidate; if (event instanceof KeyboardEvent) { if (event.repeat) return; const target = event.target as HTMLElement | null; if (target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')) { return; } candidate = normalizeDomKeyEvent(event); } else if (event.data?.type === 'iframe-keydown' && event.data.bookKey === bookKey) { const id = event.data.code || event.data.key; if (typeof id !== 'string' || !id) return; candidate = { source: 'dom', id }; } else { return; } if (handleHardwarePageTurn(candidate)) { // Stop `useShortcuts` from also paging on this key — capture-phase // for the window keydown, registration order for the iframe message. if (event instanceof KeyboardEvent) event.preventDefault(); event.stopImmediatePropagation(); } }; useEffect(() => { if (!appService?.isMobileApp) return; const viewSettings = getViewSettings(bookKey); if (viewSettings?.volumeKeysToFlip) { acquireVolumeKeyInterception(); } else { releaseVolumeKeyInterception(); } eventDispatcher.on('native-key-down', handlePageFlip); return () => { releaseVolumeKeyInterception(); eventDispatcher.off('native-key-down', handlePageFlip); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Hardware page turner: native-key + DOM-key listeners and native // media-key interception, re-evaluated whenever the setting changes. useEffect(() => { const hasNativeBinding = hardwarePageTurner?.bindings.pagePrev?.source === 'native' || hardwarePageTurner?.bindings.pageNext?.source === 'native' || hardwarePageTurner?.bindings.sectionPrev?.source === 'native' || hardwarePageTurner?.bindings.sectionNext?.source === 'native'; const needsNativeInterception = !!appService?.isMobileApp && !!hardwarePageTurner?.enabled && hasNativeBinding; if (needsNativeInterception) { acquirePageTurnerKeyInterception(); } if (hasNativeBinding) { eventDispatcher.on('native-key-down', handleHardwareNativeKey); } window.addEventListener('keydown', handleHardwareDomKey, true); window.addEventListener('message', handleHardwareDomKey); return () => { if (needsNativeInterception) { releasePageTurnerKeyInterception(); } if (hasNativeBinding) { eventDispatcher.off('native-key-down', handleHardwareNativeKey); } window.removeEventListener('keydown', handleHardwareDomKey, true); window.removeEventListener('message', handleHardwareDomKey); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ hardwarePageTurner?.enabled, hardwarePageTurner?.bindings.pagePrev?.source, hardwarePageTurner?.bindings.pageNext?.source, hardwarePageTurner?.bindings.sectionPrev?.source, hardwarePageTurner?.bindings.sectionNext?.source, ]); // 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, }; };