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(); }; // In scrolled mode, snap the page-scroll distance to whole lines so the new view // tiles cleanly: forward, the new view's top aligns to the first line that wasn't // fully visible (so no already-shown line repeats and lines aren't cut at the top); // backward, the new view's bottom aligns to the last such line. `distance` is a // positive scroll amount. Falls back to `distance` if the geometry is unavailable. const snapScrolledDistanceToLines = ( view: FoliateView, distance: number, forward: boolean, ): number => { try { const visible = view.renderer.getContents().find((c) => { const f = c.doc?.defaultView?.frameElement?.getBoundingClientRect(); return !!f && f.bottom > 1 && f.top < window.innerHeight; }); const frameEl = visible?.doc?.defaultView?.frameElement as HTMLElement | undefined; if (!visible || !frameEl) return distance; let container: Element | null = frameEl; while (container && container.id !== 'container') container = container.parentElement; if (!(container instanceof HTMLElement)) return distance; const cRect = container.getBoundingClientRect(); const frameRect = frameEl.getBoundingClientRect(); const size = cRect.height; const scrollTop = container.scrollTop; const range = visible.doc.createRange(); range.selectNodeContents(visible.doc.body); const rects = Array.from(range.getClientRects()).filter((r) => r.width > 2 && r.height > 2); if (rects.length < 2) return distance; const heights = rects.map((r) => r.height).sort((a, b) => a - b); const maxLineHeight = (heights[Math.floor(heights.length / 2)] ?? 0) * 1.8; // Line boxes in content (scroll) coordinates, sorted top-to-bottom (skip // block/container boxes that are much taller than a line). const toContent = (localY: number) => localY + frameRect.top - cRect.top + scrollTop; const lines = rects .filter((r) => r.height <= maxLineHeight) .map((r) => ({ top: toContent(r.top), bottom: toContent(r.bottom) })) .sort((a, b) => a.top - b.top); let snapped: number; if (forward) { // First line not fully visible at the bottom -> it becomes the new view's top. const bottomEdge = scrollTop + size; const next = lines.find((l) => l.bottom > bottomEdge + 1); if (!next) return distance; snapped = next.top - scrollTop; } else { // Last line not fully visible at the top -> it becomes the new view's bottom. const topEdge = scrollTop; let prev: { top: number; bottom: number } | undefined; for (let i = lines.length - 1; i >= 0; i--) { if (lines[i]!.top < topEdge - 1) { prev = lines[i]!; break; } } if (!prev) return distance; snapped = scrollTop + size - prev.bottom; } // Guard against degenerate snaps; keep within roughly one page. return snapped > size * 0.4 && snapped < size * 1.6 ? snapped : distance; } catch { return distance; } }; 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) { // `renderer.size` is already the visible content height: in scrolled mode the // scroll container is inset by the header/footer (parent padding), so `size` // shrinks when they're shown. Subtracting their heights again here would // double-count and make consecutive views overlap. const { size } = renderer; const scrollingOverlap = viewSettings.scrollingOverlap; const distance = size - scrollingOverlap; switch (mode) { case 'section': if (side === 'left' || side === 'up') { return view.renderer.prevSection?.(); } else { return view.renderer.nextSection?.(); } case 'pan': case 'page': default: { const forward = !(side === 'left' || side === 'up'); // Snap so the view's bottom edge lands between lines (not for vertical flow). const snapped = viewSettings.vertical ? distance : snapScrolledDistanceToLines(view, distance, forward); return forward ? view.next(snapped) : view.prev(snapped); } } } 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 (viewSettings?.disableSwipe) 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, }; };