import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react"; import type { BookDto } from "@readabook/shared"; import { api } from "../api/client"; import { CbzReader } from "../reader/CbzReader"; import { EpubReader } from "../reader/EpubReader"; import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell"; import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout"; import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll"; import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; import { navigate } from "../router"; import { EmptyState, LoadingState, Panel } from "../components/ui"; const idleControls: ReaderControls = { canPrevious: false, canNext: false, positionLabel: "Chargement", onPrevious: () => undefined, onNext: () => undefined }; type ReaderCrashBoundaryProps = { resetKey: string; onError: (error: Error) => void; fallbackRender: (error: Error, retry: () => void) => ReactNode; children: ReactNode; }; type ReaderCrashBoundaryState = { error: Error | null; }; class ReaderCrashBoundary extends Component { state: ReaderCrashBoundaryState = { error: null }; static getDerivedStateFromError(error: Error) { return { error }; } componentDidCatch(error: Error, _errorInfo: ErrorInfo) { this.props.onError(error); } componentDidUpdate(previousProps: ReaderCrashBoundaryProps) { if (previousProps.resetKey !== this.props.resetKey && this.state.error) { this.setState({ error: null }); } } retry = () => { this.setState({ error: null }); }; render() { if (this.state.error) return this.props.fallbackRender(this.state.error, this.retry); return this.props.children; } } export function ReaderPage({ bookId }: { bookId: number }) { const [book, setBook] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [page, setPage] = useState(1); const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT); const [mode, setMode] = useState("horizontal"); const [readerControls, setReaderControls] = useState(idleControls); const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId); const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId); async function loadBook() { setLoading(true); setError(undefined); try { setBook(await api.book(bookId)); } catch { setBook(null); setError("Ce livre n'existe pas dans le catalogue."); } finally { setLoading(false); } } useEffect(() => { void loadBook(); }, [bookId]); useEffect(() => { setReaderControls(idleControls); setPage(1); setZoom(READER_ZOOM_DEFAULT); setMode("horizontal"); }, [bookId]); useEffect(() => { if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode); else setMode("horizontal"); }, [book?.format, preferences.mode]); useEffect(() => { const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator); if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); }, [progress]); const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]); const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]); const savePdfPage = useCallback( (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { setPage(nextPage); const locator = pageLocator("pdf", nextPage, anchor); const percent = pdfPagePercent(nextPage, pages, anchor); if (strategy === "queued") queueSave(locator, percent); else void save(locator, percent); }, [queueSave, save] ); const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]); const saveComicPage = useCallback( (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { setPage(nextPage); const prefix = book?.format === "cbr" ? "cbr" : "cbz"; const locator = pageLocator(prefix, nextPage, anchor); const percent = pdfPagePercent(nextPage, pages, anchor); if (strategy === "queued") queueSave(locator, percent); else void save(locator, percent); }, [book?.format, queueSave, save] ); const readerError = error ?? progressError ?? preferencesError; const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr"; const supportsMode = supportsZoom; const currentVisiblePage = useCallback(() => { const stage = document.querySelector(".reader-stage") as HTMLElement | null; if (!stage) return null; const stageRect = stage.getBoundingClientRect(); const pages = Array.from(stage.querySelectorAll("[data-reader-page]")) .map((element) => { const rect = element.getBoundingClientRect(); const pageNumber = Number(element.dataset.readerPage); return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null; }) .filter((item): item is { page: number; top: number; bottom: number } => Boolean(item)); return majorityVisiblePage(pages, stageRect.top, stageRect.bottom); }, []); const changeMode = useCallback( (nextMode: ReaderMode) => { const anchorPage = currentVisiblePage() ?? page; setPage(anchorPage); setMode(nextMode); saveReaderMode(nextMode); }, [currentVisiblePage, page, saveReaderMode] ); const returnToPagedMode = useCallback(() => { setPage(currentVisiblePage() ?? page); setMode("horizontal"); saveReaderMode("horizontal"); }, [currentVisiblePage, page, saveReaderMode]); const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => { const stage = document.querySelector(".reader-stage") as HTMLElement | null; const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5; const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0; setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom)); requestAnimationFrame(() => { requestAnimationFrame(() => { if (!stage) return; if (anchor) { 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; return; } stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2); stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY); }); }); }, []); const zoomControls = useMemo( () => supportsZoom ? { zoom, onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor), onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP), onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP), onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT) } : undefined, [changeZoom, supportsZoom, zoom] ); const modeControls = useMemo( () => supportsMode ? { mode, onModeChange: changeMode } : undefined, [changeMode, mode, supportsMode] ); if (loading && !book) return ; if (!book) { return (
); } return ( void loadBook() : undefined} controls={readerControls} zoomControls={zoomControls} modeControls={modeControls} > setReaderControls(idleControls)} fallbackRender={(crashError, retry) => (

Le lecteur a rencontré une erreur.

La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.

{crashError.message && (
Détail technique
{crashError.message}
)}
)} > {book.format === "pdf" ? ( ) : book.format === "cbz" || book.format === "cbr" ? ( ) : ( )}
); }