import { ArrowLeft, ArrowRight, Maximize2, Minimize2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { ErrorRibbon } from "../components/ui"; import { navigate } from "../router"; import { enterElementFullscreen, exitElementFullscreen, isElementFullscreen, isReaderHeaderActive, readerNavigationAction, shouldHandleReaderNavigationKey, supportsElementFullscreen } from "./readerFullscreen"; import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; import type { ReaderMode } from "./readerScroll"; import type { ReadingConnectionMode } from "../api/types"; export type ReaderControls = { canPrevious: boolean; canNext: boolean; positionLabel: string; onPrevious: () => void; onNext: () => void; }; export type ReaderZoomControls = { zoom: number; onZoomChange: (zoom: number, anchor?: ReaderZoomAnchor) => void; onZoomOut: () => void; onZoomIn: () => void; onZoomReset: () => void; }; export type ReaderModeControls = { mode: ReaderMode; onModeChange: (mode: ReaderMode) => void; }; export type ReaderConnectionControls = { mode: ReadingConnectionMode; onModeChange: (mode: ReadingConnectionMode) => void; }; type ReaderShellProps = { title: string; backHref: string; error?: string; onRetry?: () => void; controls: ReaderControls; zoomControls?: ReaderZoomControls; modeControls?: ReaderModeControls; connectionControls?: ReaderConnectionControls; activity?: string; children: ReactNode; }; export type ReaderZoomAnchor = { page: number; clientX: number; clientY: number; offsetX: number; offsetY: number; ratioX: number; ratioY: number; }; type ReaderPinchGesture = { distance: number; zoom: number; anchor: ReaderZoomAnchor | null; }; function readerTouchPoint(touch: Touch): ReaderPoint { return { x: touch.clientX, y: touch.clientY }; } function readerTouchMidpoint(first: Touch, second: Touch): ReaderPoint { return { x: (first.clientX + second.clientX) / 2, y: (first.clientY + second.clientY) / 2 }; } function readerZoomAnchorAt(point: ReaderPoint): ReaderZoomAnchor | null { const pageElement = document.elementFromPoint(point.x, point.y)?.closest("[data-reader-page]"); if (!(pageElement instanceof HTMLElement)) return null; const page = Number(pageElement.dataset.readerPage); const rect = pageElement.getBoundingClientRect(); if (!Number.isFinite(page) || rect.width <= 0 || rect.height <= 0) return null; return { page, clientX: point.x, clientY: point.y, offsetX: point.x - rect.left, offsetY: point.y - rect.top, ratioX: Math.max(0, Math.min(1, (point.x - rect.left) / rect.width)), ratioY: Math.max(0, Math.min(1, (point.y - rect.top) / rect.height)) }; } function restoreReaderZoomAnchor(stage: HTMLElement, anchor: ReaderZoomAnchor) { const target = stage.querySelector(`[data-reader-page="${anchor.page}"]`); if (!target) return; const rect = target.getBoundingClientRect(); stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX; stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY; } function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomAnchor) { requestAnimationFrame(() => { requestAnimationFrame(() => restoreReaderZoomAnchor(stage, anchor)); }); } export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) { const readerRef = useRef(null); const headerRef = useRef(null); const stageRef = useRef(null); const headerHideTimerRef = useRef(undefined); const tapStartRef = useRef(null); const activeTouchPointsRef = useRef(new Map()); const pinchRef = useRef(null); const [nativeFullscreen, setNativeFullscreen] = useState(false); const [fallbackFullscreen, setFallbackFullscreen] = useState(false); const [headerVisible, setHeaderVisible] = useState(true); const fullscreenActive = nativeFullscreen || fallbackFullscreen; const readerMode = modeControls?.mode ?? "horizontal"; const isMobileReaderViewport = useCallback(() => window.matchMedia("(max-width: 900px) and (pointer: coarse)").matches, []); const shouldUseTapOnlyHeaderReveal = useCallback(() => readerMode === "vertical" && isMobileReaderViewport(), [isMobileReaderViewport, readerMode]); const clearHeaderHideTimer = useCallback(() => { if (headerHideTimerRef.current === undefined) return; window.clearTimeout(headerHideTimerRef.current); headerHideTimerRef.current = undefined; }, []); const scheduleHeaderHide = useCallback(() => { clearHeaderHideTimer(); if (!fullscreenActive) { setHeaderVisible(true); return; } headerHideTimerRef.current = window.setTimeout(() => { if (isReaderHeaderActive(headerRef.current, document)) { scheduleHeaderHide(); return; } setHeaderVisible(false); }, 2000); }, [clearHeaderHideTimer, fullscreenActive]); const revealHeader = useCallback(() => { if (!fullscreenActive) return; setHeaderVisible(true); scheduleHeaderHide(); }, [fullscreenActive, scheduleHeaderHide]); const revealHeaderForPassiveGesture = useCallback(() => { if (shouldUseTapOnlyHeaderReveal()) return; revealHeader(); }, [revealHeader, shouldUseTapOnlyHeaderReveal]); const toggleFullscreen = useCallback(async () => { const readerElement = readerRef.current; if (!readerElement) return; if (isElementFullscreen(readerElement, document)) { await exitElementFullscreen(document); return; } if (fallbackFullscreen) { setFallbackFullscreen(false); return; } if (supportsElementFullscreen(readerElement)) { try { await enterElementFullscreen(readerElement); return; } catch { setFallbackFullscreen(true); return; } } setFallbackFullscreen(true); }, [fallbackFullscreen]); useEffect(() => { const syncFullscreen = () => { const readerElement = readerRef.current; setNativeFullscreen(isElementFullscreen(readerElement, document)); }; syncFullscreen(); document.addEventListener("fullscreenchange", syncFullscreen); return () => document.removeEventListener("fullscreenchange", syncFullscreen); }, []); useEffect(() => { if (fullscreenActive) { setHeaderVisible(true); scheduleHeaderHide(); return; } clearHeaderHideTimer(); setHeaderVisible(true); }, [clearHeaderHideTimer, fullscreenActive, scheduleHeaderHide]); useEffect(() => clearHeaderHideTimer, [clearHeaderHideTimer]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && fallbackFullscreen) { setFallbackFullscreen(false); return; } const mode = modeControls?.mode ?? "horizontal"; if (!shouldHandleReaderNavigationKey(event, readerRef.current, mode)) return; revealHeader(); const action = readerNavigationAction(event, mode); if (mode === "vertical") { event.preventDefault(); stageRef.current?.scrollBy({ top: (action === "next" ? 1 : -1) * Math.max(120, stageRef.current.clientHeight * 0.82), behavior: "smooth" }); return; } if (action === "previous" && controls.canPrevious) { event.preventDefault(); controls.onPrevious(); } if (action === "next" && controls.canNext) { event.preventDefault(); controls.onNext(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [controls, fallbackFullscreen, modeControls?.mode, revealHeader]); useEffect(() => { const stage = stageRef.current; if (!stage || !zoomControls) return; const startPinch = (event: TouchEvent) => { if (!isMobileReaderViewport() || event.touches.length !== 2) { pinchRef.current = null; stage.removeAttribute("data-reader-pinch-active"); return; } event.preventDefault(); const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]); const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1])); stage.dataset.readerPinchActive = "true"; tapStartRef.current = null; pinchRef.current = { distance, zoom: zoomControls.zoom, anchor: readerZoomAnchorAt(midpoint) }; }; const movePinch = (event: TouchEvent) => { if (!pinchRef.current || event.touches.length !== 2) return; event.preventDefault(); const midpoint = readerTouchMidpoint(event.touches[0], event.touches[1]); const distance = readerPointDistance(readerTouchPoint(event.touches[0]), readerTouchPoint(event.touches[1])); const anchor = pinchRef.current.anchor ? { ...pinchRef.current.anchor, clientX: midpoint.x, clientY: midpoint.y } : null; zoomControls.onZoomChange(readerPinchZoom(pinchRef.current.zoom, pinchRef.current.distance, distance), anchor ?? undefined); if (anchor) scheduleReaderZoomAnchorRestore(stage, anchor); }; const endPinch = (event: TouchEvent) => { if (event.touches.length >= 2) return; pinchRef.current = null; stage.removeAttribute("data-reader-pinch-active"); }; stage.addEventListener("touchstart", startPinch, { passive: false }); stage.addEventListener("touchmove", movePinch, { passive: false }); stage.addEventListener("touchend", endPinch); stage.addEventListener("touchcancel", endPinch); return () => { stage.removeEventListener("touchstart", startPinch); stage.removeEventListener("touchmove", movePinch); stage.removeEventListener("touchend", endPinch); stage.removeEventListener("touchcancel", endPinch); }; }, [isMobileReaderViewport, zoomControls]); const handleReaderPointerDown = useCallback( (event: ReactPointerEvent) => { if (!shouldUseTapOnlyHeaderReveal()) { revealHeader(); return; } if (event.pointerType !== "touch" || !event.isPrimary || activeTouchPointsRef.current.size > 0) { tapStartRef.current = null; return; } activeTouchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); tapStartRef.current = { x: event.clientX, y: event.clientY }; }, [revealHeader, shouldUseTapOnlyHeaderReveal] ); const handleReaderPointerMove = useCallback( (event: ReactPointerEvent) => { if (!shouldUseTapOnlyHeaderReveal()) return; const current = { x: event.clientX, y: event.clientY }; if (activeTouchPointsRef.current.has(event.pointerId)) activeTouchPointsRef.current.set(event.pointerId, current); const tapStart = tapStartRef.current; if (tapStart && readerMovedBeyondTap(tapStart, current)) tapStartRef.current = null; }, [shouldUseTapOnlyHeaderReveal] ); const handleReaderPointerUp = useCallback( (event: ReactPointerEvent) => { if (!shouldUseTapOnlyHeaderReveal()) return; activeTouchPointsRef.current.delete(event.pointerId); const tapStart = tapStartRef.current; tapStartRef.current = null; if (event.pointerType === "touch" && event.isPrimary && tapStart && !readerMovedBeyondTap(tapStart, { x: event.clientX, y: event.clientY })) { revealHeader(); } }, [revealHeader, shouldUseTapOnlyHeaderReveal] ); const handleReaderPointerCancel = useCallback((event: ReactPointerEvent) => { activeTouchPointsRef.current.delete(event.pointerId); tapStartRef.current = null; }, []); return (
{ clearHeaderHideTimer(); setHeaderVisible(true); }} onMouseEnter={() => { clearHeaderHideTimer(); setHeaderVisible(true); }} onMouseLeave={scheduleHeaderHide} onPointerDown={() => { clearHeaderHideTimer(); setHeaderVisible(true); }} > {title} {controls.positionLabel} {modeControls && ( )} {connectionControls && ( )} {zoomControls && (
{readerZoomLabel(zoomControls.zoom)}
)}
{error && (
{onRetry && ( )}
)} {!error && activity && (
{activity}
)}
{children}
); }