import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import type { CbzPagesDto } from "../api/types"; import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout"; import { majorityVisiblePage, type ReaderMode } from "./readerScroll"; import type { ReaderControls } from "./ReaderShell"; type PageCommitStrategy = "immediate" | "queued"; export function CbzReader({ bookId, page, zoom, mode, onPageCommit, onControlsChange }: { bookId: number; page: number; zoom: number; mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }) { const frameRef = useRef(null); const previousModeRef = useRef(mode); const [pages, setPages] = useState(null); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); const [documentAttempt, setDocumentAttempt] = useState(0); const [retryAttempt, setRetryAttempt] = useState(0); const [viewportSize, setViewportSize] = useState(null); const [imageSize, setImageSize] = useState(null); const [imageSizes, setImageSizes] = useState>({}); useEffect(() => { let alive = true; setDocumentError(undefined); setPageError(undefined); setPages(null); api .cbzPages(bookId) .then((nextPages) => { if (!alive) return; setPages(nextPages); if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate"); }) .catch((error) => { if (!alive) return; setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible."); }); return () => { alive = false; }; }, [bookId, documentAttempt]); const pageCount = pages?.pageCount ?? 1; const currentPage = clampReaderPage(page, pageCount); const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null; const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined; const verticalImageStyle = useCallback( (pageNumber: number) => { const size = imageSizes[pageNumber]; if (!viewportSize || !size) return undefined; const fitted = zoomReaderSize(orientedContainPageSize(viewportSize, size), zoom); return { width: `${fitted.width}px`, height: `${fitted.height}px` }; }, [imageSizes, viewportSize, zoom] ); const go = useCallback( (nextPage: number) => { if (!pages) return; const target = clampReaderPage(nextPage, pages.pageCount); setPageError(undefined); if (mode === "vertical") { frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); } onPageCommit(target, pages.pageCount, 1, "immediate"); }, [mode, onPageCommit, pages] ); useEffect(() => { setPageError(undefined); setImageSize(null); }, [bookId, currentPage, retryAttempt]); useEffect(() => { setImageSizes({}); }, [bookId, retryAttempt]); useEffect(() => { const frame = frameRef.current; const stage = frame?.closest(".reader-stage") as HTMLElement | null; const observedElement = stage ?? frame; if (!observedElement) return; const updateSize = () => { const rect = observedElement.getBoundingClientRect(); const nextSize = readableViewportSize({ width: rect.width, height: rect.height }); if (nextSize) setViewportSize(nextSize); }; updateSize(); const frameId = requestAnimationFrame(updateSize); const observer = new ResizeObserver(updateSize); observer.observe(observedElement); return () => { cancelAnimationFrame(frameId); observer.disconnect(); }; }, []); useEffect(() => { onControlsChange({ canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1, canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount, positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement", onPrevious: () => go(currentPage - 1), onNext: () => go(currentPage + 1) }); }, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]); useEffect(() => { if (mode !== "vertical") { previousModeRef.current = mode; return; } if (previousModeRef.current !== "vertical") { requestAnimationFrame(() => { frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); }); } previousModeRef.current = mode; }, [currentPage, mode]); useEffect(() => { if (mode !== "vertical" || !pages) return; const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; if (!stage) return; let frameId = 0; const updateVisiblePage = () => { frameId = 0; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { const rect = element.getBoundingClientRect(); return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom }; }), stageRect.top, stageRect.bottom ); if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); }; const onScroll = () => { if (frameId) return; frameId = requestAnimationFrame(updateVisiblePage); }; stage.addEventListener("scroll", onScroll, { passive: true }); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); stage.removeEventListener("scroll", onScroll); }; }, [currentPage, mode, onPageCommit, pages]); if (documentError) { return (
{documentError}
); } if (!pages) { return (
Chargement de l'archive
); } if (pageError) { return (
{pageError}
); } return (
{mode === "vertical" ? (
{pages.pages.map((item) => (
{!imageSizes[item.page] && (
Page {item.page}
)} { setImageSizes((current) => ({ ...current, [item.page]: { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight } })); }} onError={() => setPageError(`Page ${item.page} indisponible.`)} />
))}
) : (
{!imageSize && (
Page {currentPage}
)} { setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); }} onError={() => setPageError(`Page ${currentPage} indisponible.`)} />
)}
); }