import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; import { ReaderError } from "./ReaderError"; import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; import { configurePdfWorker } from "./pdfWorker"; import type { ReaderControls } from "./ReaderShell"; import type { ReaderMode } from "../api/types"; configurePdfWorker(pdfjs); type PageCommitStrategy = "immediate" | "queued"; type PdfReaderProps = { url: string; page: number; backHref: string; mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }; type PdfPageCanvasProps = { document: pdfjs.PDFDocumentProxy; pageNumber: number; mode: ReaderMode; frameSize: { width: number; height: number }; onError: (message: string) => void; }; let pdfLifecycle = Promise.resolve(); function enqueuePdfLifecycle(work: () => Promise) { const run = pdfLifecycle.then(work, work); pdfLifecycle = run.then(() => undefined, () => undefined); return run; } function isPdfTransitionError(error: unknown) { return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled")); } function pdfReaderErrorMessage(error: unknown) { if (isPdfTransitionError(error)) return "Le lecteur PDF termine une ouverture precedente."; return "Le PDF n'a pas pu s'ouvrir."; } function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { return element?.closest(".reader-stage") as HTMLElement | null; } function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) { const canvasRef = useRef(null); const wrapperRef = useRef(null); const [visible, setVisible] = useState(mode === "horizontal"); const [rendered, setRendered] = useState(false); useEffect(() => { if (mode === "horizontal") { setVisible(true); return; } const wrapper = wrapperRef.current; if (!wrapper) return; const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) setVisible(true); }, { root: scrollContainerFor(wrapper), rootMargin: "900px 0px" } ); observer.observe(wrapper); return () => observer.disconnect(); }, [mode]); useEffect(() => { if (!visible) return; let cancelled = false; let renderTask: pdfjs.RenderTask | undefined; setRendered(false); async function renderPage() { try { const pdfPage = await document.getPage(pageNumber); if (cancelled) return; const canvas = canvasRef.current; if (!canvas) return; const baseViewport = pdfPage.getViewport({ scale: 1 }); const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height }); const pixelRatio = Math.min(2, window.devicePixelRatio || 1); const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio; const viewport = pdfPage.getViewport({ scale: renderScale }); const cssWidth = Math.floor(viewport.width / pixelRatio); const cssHeight = Math.floor(viewport.height / pixelRatio); canvas.width = Math.max(1, Math.floor(viewport.width)); canvas.height = Math.max(1, Math.floor(viewport.height)); canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; const context = canvas.getContext("2d", { alpha: false }); if (!context) return; context.fillStyle = "#f7f0df"; context.fillRect(0, 0, canvas.width, canvas.height); renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); await renderTask.promise; if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) { throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`); } if (!cancelled) setRendered(true); } catch (error) { if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error)); } } void enqueuePdfLifecycle(renderPage); return () => { cancelled = true; renderTask?.cancel(); }; }, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]); return (
{!rendered && (
Page {pageNumber}
)}
); } export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); const restoredRef = useRef(false); const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); const [visiblePage, setVisiblePage] = useState(page); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [attempt, setAttempt] = useState(0); const [frameSize, setFrameSize] = useState({ width: 980, height: 900 }); const currentPage = Math.max(1, Math.min(page, pages)); const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pages)) : currentPage; const go = useCallback( (nextPage: number) => { const target = Math.max(1, Math.min(nextPage, pages)); if (mode === "vertical") { frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); onPageCommit(target, pages, 0, "immediate"); setVisiblePage(target); } else { onPageCommit(target, pages, 1, "immediate"); } }, [mode, onPageCommit, pages] ); const goTop = useCallback(() => go(1), [go]); const handlePageError = useCallback((message: string) => { setError(message); }, []); useEffect(() => { onControlsChange({ canPrevious: !loading && !error && displayedPage > 1, canNext: !loading && !error && displayedPage < pages, canTop: !loading && !error && displayedPage > 1, positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`, onPrevious: () => go(displayedPage - 1), onNext: () => go(displayedPage + 1), onTop: goTop }); }, [displayedPage, error, go, goTop, loading, onControlsChange, pages]); useEffect(() => { const frame = frameRef.current; if (!frame) return; const updateSize = () => { const rect = frame.getBoundingClientRect(); setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) }); }; updateSize(); const observer = new ResizeObserver(updateSize); observer.observe(frame); return () => observer.disconnect(); }, []); useEffect(() => { let cancelled = false; let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined; setLoading(true); setError(undefined); setDocumentProxy(null); restoredRef.current = false; async function loadDocument() { try { configurePdfWorker(pdfjs); loadingTask = pdfjs.getDocument({ url, withCredentials: true }); const loadedDocument = await loadingTask.promise; if (cancelled) { await loadingTask.destroy(); return; } setPages(loadedDocument.numPages); setDocumentProxy(loadedDocument); setLoading(false); } catch (loadError) { if (cancelled && isPdfTransitionError(loadError)) return; if (!cancelled) { setError(pdfReaderErrorMessage(loadError)); setLoading(false); } } } void enqueuePdfLifecycle(loadDocument); return () => { cancelled = true; void loadingTask?.destroy(); }; }, [attempt, url]); useEffect(() => { if (mode !== "vertical") return; const root = scrollContainerFor(frameRef.current); if (!root || !documentProxy) return; const handleScroll = () => { const rootRect = root.getBoundingClientRect(); const frames = Array.from(frameRef.current?.querySelectorAll("[data-reader-page]") ?? []); const active = frames .map((element) => { const rect = element.getBoundingClientRect(); const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top); const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height))); return { page: Number(element.dataset.readerPage), overlap, anchor }; }) .filter((item) => Number.isInteger(item.page) && item.overlap > 0) .sort((left, right) => right.overlap - left.overlap)[0]; if (!active) return; setVisiblePage(active.page); onPageCommit(active.page, pages, active.anchor, "queued"); }; root.addEventListener("scroll", handleScroll, { passive: true }); handleScroll(); return () => root.removeEventListener("scroll", handleScroll); }, [documentProxy, mode, onPageCommit, pages]); useEffect(() => { if (mode !== "vertical" || !documentProxy || restoredRef.current) return; restoredRef.current = true; requestAnimationFrame(() => { frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); setVisiblePage(currentPage); }); }, [currentPage, documentProxy, mode]); return (
{error ? ( setAttempt((value) => value + 1)} /> ) : loading || !documentProxy ? (
Ouverture PDF
) : mode === "vertical" ? (
{Array.from({ length: pages }, (_, index) => ( ))}
) : ( )}
); }