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"; const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; 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(null); const pendingVerticalAnchorRef = useRef(false); const verticalTrackingReadyRef = useRef(false); const verticalUserScrollRef = useRef(false); const verticalAnchorTargetPageRef = useRef(null); const verticalAnchorFrameRef = useRef(null); const verticalInitialSyncFrameRef = useRef(null); const verticalInitialSyncAttemptRef = useRef(0); const verticalInitialSyncDoneRef = useRef(false); const verticalAnchorAttemptRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0); 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 clearVerticalAnchorFrame = useCallback(() => { if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current); verticalAnchorFrameRef.current = null; }, []); const clearVerticalInitialSync = useCallback(() => { if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); verticalInitialSyncFrameRef.current = null; }, []); const commitVisiblePage = useCallback( (stage: HTMLElement, allowInitialCommit = false) => { if (!pages || !verticalTrackingReadyRef.current) return; if (!allowInitialCommit && !verticalUserScrollRef.current) return; if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; 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) { if (allowInitialCommit) verticalInitialSyncDoneRef.current = true; onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); } }, [currentPage, onPageCommit, pages] ); const scheduleInitialVisibleSync = useCallback( (stage: HTMLElement) => { clearVerticalInitialSync(); verticalInitialSyncAttemptRef.current = 0; const sync = () => { verticalInitialSyncFrameRef.current = null; if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return; if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) { commitVisiblePage(stage, true); } verticalInitialSyncAttemptRef.current += 1; if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return; verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); }; verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); }, [clearVerticalInitialSync, commitVisiblePage] ); const stabilizeVerticalAnchor = useCallback( (targetPage: number) => { clearVerticalAnchorFrame(); verticalAnchorAttemptRef.current = 0; verticalAnchorStableFramesRef.current = 0; verticalTrackingReadyRef.current = false; const measure = () => { const frame = frameRef.current; const stage = frame?.closest(".reader-stage") as HTMLElement | null; const target = frame?.querySelector(`[data-reader-page="${targetPage}"]`); if (!stage || !target) return; target.scrollIntoView({ block: "start" }); verticalAnchorFrameRef.current = requestAnimationFrame(() => { verticalAnchorFrameRef.current = null; const stageRect = stage.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX; const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2; verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0; if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) { verticalTrackingReadyRef.current = true; commitVisiblePage(stage, true); scheduleInitialVisibleSync(stage); return; } verticalAnchorAttemptRef.current += 1; if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return; verticalAnchorFrameRef.current = requestAnimationFrame(measure); }); }; verticalAnchorFrameRef.current = requestAnimationFrame(measure); }, [clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync] ); const go = useCallback( (nextPage: number) => { if (!pages) return; const target = clampReaderPage(nextPage, pages.pageCount); setPageError(undefined); if (mode === "vertical") { verticalUserScrollRef.current = false; 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; pendingVerticalAnchorRef.current = false; verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; verticalAnchorTargetPageRef.current = null; verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); clearVerticalInitialSync(); return; } const enteringVertical = previousModeRef.current !== "vertical"; if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { pendingVerticalAnchorRef.current = true; verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; if (enteringVertical) verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); clearVerticalInitialSync(); } if (pendingVerticalAnchorRef.current) { const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); if (!target) { previousModeRef.current = mode; return; } pendingVerticalAnchorRef.current = false; verticalAnchorTargetPageRef.current = currentPage; stabilizeVerticalAnchor(currentPage); } previousModeRef.current = mode; }, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, mode, pages, stabilizeVerticalAnchor]); useEffect(() => { if (mode !== "vertical" || !pages) return; const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; if (!stage) return; let frameId = 0; const markUserScroll = () => { verticalUserScrollRef.current = true; }; const markUserScrollKey = (event: KeyboardEvent) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll(); }; const keyTarget = typeof window === "undefined" ? null : window; const updateVisiblePage = () => { frameId = 0; commitVisiblePage(stage); }; const onScroll = () => { if (frameId) return; frameId = requestAnimationFrame(updateVisiblePage); }; stage.addEventListener("wheel", markUserScroll, { passive: true }); stage.addEventListener("touchmove", markUserScroll, { passive: true }); stage.addEventListener("pointerdown", markUserScroll, { passive: true }); keyTarget?.addEventListener("keydown", markUserScrollKey); stage.addEventListener("scroll", onScroll, { passive: true }); scheduleInitialVisibleSync(stage); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); stage.removeEventListener("wheel", markUserScroll); stage.removeEventListener("touchmove", markUserScroll); stage.removeEventListener("pointerdown", markUserScroll); keyTarget?.removeEventListener("keydown", markUserScrollKey); stage.removeEventListener("scroll", onScroll); }; }, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]); useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); 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}
)} {item.name} { const { naturalWidth, naturalHeight } = event.currentTarget; setImageSizes((current) => ({ ...current, [item.page]: { width: naturalWidth, height: naturalHeight } })); }} onError={() => setPageError(`Page ${item.page} indisponible.`)} />
))}
) : (
{!imageSize && (
Page {currentPage}
)} { setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); }} onError={() => setPageError(`Page ${currentPage} indisponible.`)} />
)}
); }