494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import * as pdfjs from "pdfjs-dist";
|
|
import { ReaderError } from "./ReaderError";
|
|
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
|
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
|
import { configurePdfWorker } from "./pdfWorker";
|
|
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
|
|
import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
|
import type { ReaderControls } from "./ReaderShell";
|
|
|
|
configurePdfWorker(pdfjs);
|
|
|
|
type PageCommitStrategy = "immediate" | "queued";
|
|
|
|
type PdfReaderProps = {
|
|
url: string;
|
|
page: number;
|
|
backHref: string;
|
|
zoom: number;
|
|
mode: ReaderMode;
|
|
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
|
onControlsChange: (controls: ReaderControls) => void;
|
|
};
|
|
|
|
type PdfFailure = {
|
|
detail: string;
|
|
technicalDetail: string;
|
|
};
|
|
|
|
type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed";
|
|
|
|
function pdfTechnicalMessage(error: unknown) {
|
|
if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
|
|
if (typeof error === "string" && error.trim()) return error;
|
|
return "Erreur inconnue.";
|
|
}
|
|
|
|
function pdfFailure(source: string, detail: string, context: Record<string, unknown>, error?: unknown): PdfFailure {
|
|
return {
|
|
detail,
|
|
technicalDetail: JSON.stringify(
|
|
{
|
|
source,
|
|
...context,
|
|
error: error === undefined ? undefined : pdfTechnicalMessage(error)
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
};
|
|
}
|
|
|
|
function isPdfTransitionError(error: unknown) {
|
|
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
|
|
}
|
|
|
|
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
|
return element?.closest(".reader-stage") as HTMLElement | null;
|
|
}
|
|
|
|
function afterNextPaint() {
|
|
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
}
|
|
|
|
function PdfPageCanvas({
|
|
documentProxy,
|
|
pageNumber,
|
|
viewportSize,
|
|
zoom,
|
|
active
|
|
}: {
|
|
documentProxy: pdfjs.PDFDocumentProxy;
|
|
pageNumber: number;
|
|
viewportSize: ReaderSize | null;
|
|
zoom: number;
|
|
active: boolean;
|
|
}) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [rendered, setRendered] = useState(false);
|
|
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
|
const [pageError, setPageError] = useState<string>();
|
|
const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError));
|
|
|
|
useEffect(() => {
|
|
if (!active || !viewportSize) return;
|
|
const readerViewport = viewportSize;
|
|
let cancelled = false;
|
|
let renderTask: pdfjs.RenderTask | undefined;
|
|
setLoading(true);
|
|
setRendered(false);
|
|
setPageStatus("loading");
|
|
setPageError(undefined);
|
|
|
|
async function renderPage() {
|
|
try {
|
|
const pdfPage = await documentProxy.getPage(pageNumber);
|
|
if (cancelled) return;
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
|
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
|
|
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
|
const viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio });
|
|
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) throw new Error("PDF canvas context unavailable");
|
|
context.fillStyle = pdfSentinelBackground();
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
|
await renderTask.promise;
|
|
await afterNextPaint();
|
|
if (cancelled) return;
|
|
setPageStatus(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height)));
|
|
setRendered(true);
|
|
setLoading(false);
|
|
} catch (error) {
|
|
if (cancelled || isPdfTransitionError(error)) return;
|
|
setPageStatus("render-failed");
|
|
setLoading(false);
|
|
setPageError(`Page ${pageNumber} indisponible.`);
|
|
}
|
|
}
|
|
|
|
void renderPage();
|
|
return () => {
|
|
cancelled = true;
|
|
renderTask?.cancel();
|
|
};
|
|
}, [active, documentProxy, pageNumber, viewportSize, zoom]);
|
|
|
|
return (
|
|
<div className="pdf-page-frame pdf-page-frame-vertical" data-reader-page={pageNumber}>
|
|
{(!active || !viewportSize || loading || !rendered || pageError) && (
|
|
<div className={`reader-fallback${pageError ? " reader-fallback-error" : ""}`}>
|
|
<span>{pageError ?? `Page ${pageNumber}`}</span>
|
|
</div>
|
|
)}
|
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const previousModeRef = useRef<ReaderMode>(mode);
|
|
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
|
const [pages, setPages] = useState(1);
|
|
const [documentError, setDocumentError] = useState<PdfFailure>();
|
|
const [pageError, setPageError] = useState<PdfFailure>();
|
|
const [loadingDocument, setLoadingDocument] = useState(true);
|
|
const [loadingPage, setLoadingPage] = useState(false);
|
|
const [pageRendered, setPageRendered] = useState(false);
|
|
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
|
const [documentAttempt, setDocumentAttempt] = useState(0);
|
|
const [pageAttempt, setPageAttempt] = useState(0);
|
|
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
|
|
|
const currentPage = clampReaderPage(page, pages);
|
|
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
|
|
|
|
const go = useCallback(
|
|
(nextPage: number) => {
|
|
const target = clampReaderPage(nextPage, pages);
|
|
setPageError(undefined);
|
|
if (mode === "vertical") {
|
|
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
|
}
|
|
onPageCommit(target, pages, 1, "immediate");
|
|
},
|
|
[mode, onPageCommit, pages]
|
|
);
|
|
|
|
useEffect(() => {
|
|
onControlsChange({
|
|
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
|
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
|
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
|
|
onPrevious: () => go(currentPage - 1),
|
|
onNext: () => go(currentPage + 1)
|
|
});
|
|
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "vertical") {
|
|
previousModeRef.current = mode;
|
|
return;
|
|
}
|
|
if (previousModeRef.current !== "vertical") {
|
|
requestAnimationFrame(() => {
|
|
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
|
});
|
|
}
|
|
previousModeRef.current = mode;
|
|
}, [currentPage, mode]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "vertical") return;
|
|
const stage = scrollContainerFor(frameRef.current);
|
|
if (!stage) return;
|
|
let frameId = 0;
|
|
const updateVisiblePage = () => {
|
|
frameId = 0;
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const visiblePage = majorityVisiblePage(
|
|
Array.from(stage.querySelectorAll<HTMLElement>("[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), pages, 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]);
|
|
|
|
useEffect(() => {
|
|
const frame = frameRef.current;
|
|
const stage = scrollContainerFor(frame);
|
|
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(() => {
|
|
let cancelled = false;
|
|
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
|
setLoadingDocument(true);
|
|
setDocumentError(undefined);
|
|
setPageError(undefined);
|
|
setPageRendered(false);
|
|
setPageStatus("idle");
|
|
setDocumentProxy(null);
|
|
|
|
async function loadDocument() {
|
|
try {
|
|
try {
|
|
new URL(url, window.location.href);
|
|
} catch (error) {
|
|
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
|
|
}
|
|
configurePdfWorker(pdfjs);
|
|
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
|
|
const loadedDocument = await loadingTask.promise;
|
|
if (cancelled) {
|
|
await loadingTask.destroy();
|
|
return;
|
|
}
|
|
setPages(loadedDocument.numPages);
|
|
setDocumentProxy(loadedDocument);
|
|
setLoadingDocument(false);
|
|
} catch (error) {
|
|
if (cancelled && isPdfTransitionError(error)) return;
|
|
if (!cancelled) {
|
|
setDocumentError(
|
|
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
|
? (error as PdfFailure)
|
|
: pdfFailure(
|
|
"getDocument",
|
|
"PDF: getDocument() a échoué.",
|
|
{
|
|
url,
|
|
resolvedUrl: (() => {
|
|
try {
|
|
return new URL(url, window.location.href).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
})(),
|
|
workerSrc: pdfjs.GlobalWorkerOptions.workerSrc,
|
|
hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort)
|
|
},
|
|
error
|
|
)
|
|
);
|
|
setLoadingDocument(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void loadDocument();
|
|
return () => {
|
|
cancelled = true;
|
|
void loadingTask?.destroy();
|
|
};
|
|
}, [documentAttempt, url]);
|
|
|
|
useEffect(() => {
|
|
if (mode === "vertical") {
|
|
setPageError(undefined);
|
|
return;
|
|
}
|
|
if (!documentProxy || !viewportSize) return;
|
|
const loadedDocument = documentProxy;
|
|
const readerViewport = viewportSize;
|
|
let cancelled = false;
|
|
let renderTask: pdfjs.RenderTask | undefined;
|
|
setLoadingPage(true);
|
|
setPageError(undefined);
|
|
setPageRendered(false);
|
|
setPageStatus("loading");
|
|
|
|
async function renderPage() {
|
|
try {
|
|
let pdfPage: pdfjs.PDFPageProxy;
|
|
try {
|
|
pdfPage = await loadedDocument.getPage(currentPage);
|
|
} catch (error) {
|
|
throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error);
|
|
}
|
|
if (cancelled) return;
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage });
|
|
}
|
|
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
|
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
|
|
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
|
const zoomedScale = fitScale * readerZoomFactor(zoom);
|
|
const renderScale = Math.max(0.25, zoomedScale) * 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) {
|
|
throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, {
|
|
pageNumber: currentPage,
|
|
canvasWidth: canvas.width,
|
|
canvasHeight: canvas.height
|
|
});
|
|
}
|
|
if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) {
|
|
throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, {
|
|
pageNumber: currentPage,
|
|
canvasWidth: canvas.width,
|
|
canvasHeight: canvas.height,
|
|
cssWidth,
|
|
cssHeight,
|
|
readerViewport
|
|
});
|
|
}
|
|
context.fillStyle = pdfSentinelBackground();
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
try {
|
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
|
await renderTask.promise;
|
|
} catch (error) {
|
|
throw pdfFailure(
|
|
"render-failed",
|
|
`PDF: render() a échoué pour la page ${currentPage}.`,
|
|
{
|
|
result: "render-failed",
|
|
pageNumber: currentPage,
|
|
baseViewport: { width: baseViewport.width, height: baseViewport.height },
|
|
renderViewport: { width: viewport.width, height: viewport.height },
|
|
canvasWidth: canvas.width,
|
|
canvasHeight: canvas.height,
|
|
cssWidth,
|
|
cssHeight,
|
|
readerViewport
|
|
},
|
|
error
|
|
);
|
|
}
|
|
await afterNextPaint();
|
|
if (cancelled) return;
|
|
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
|
|
if (renderResult === "blank-detected") {
|
|
setPageStatus("blank-detected");
|
|
setPageRendered(true);
|
|
setLoadingPage(false);
|
|
return;
|
|
}
|
|
if (!cancelled) {
|
|
setPageStatus("rendered-ok");
|
|
setPageRendered(true);
|
|
setLoadingPage(false);
|
|
}
|
|
} catch (error) {
|
|
if (cancelled || isPdfTransitionError(error)) return;
|
|
setPageRendered(false);
|
|
setPageStatus("render-failed");
|
|
setLoadingPage(false);
|
|
setPageError(
|
|
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
|
? (error as PdfFailure)
|
|
: pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error)
|
|
);
|
|
}
|
|
}
|
|
|
|
void renderPage();
|
|
return () => {
|
|
cancelled = true;
|
|
renderTask?.cancel();
|
|
};
|
|
}, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]);
|
|
|
|
if (documentError) {
|
|
return (
|
|
<div className="pdf-reader" ref={frameRef}>
|
|
<ReaderError
|
|
title="Lecture PDF indisponible"
|
|
detail={documentError.detail}
|
|
technicalDetail={documentError.technicalDetail}
|
|
downloadUrl={url}
|
|
backHref={backHref}
|
|
onRetry={() => setDocumentAttempt((value) => value + 1)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
|
{loadingDocument || !documentProxy ? (
|
|
<div className="reader-fallback">
|
|
<span>Ouverture PDF</span>
|
|
</div>
|
|
) : mode === "vertical" ? (
|
|
<div className="reader-vertical-stack">
|
|
{Array.from({ length: pages }, (_, index) => {
|
|
const pageNumber = index + 1;
|
|
return (
|
|
<PdfPageCanvas
|
|
key={pageNumber}
|
|
documentProxy={documentProxy}
|
|
pageNumber={pageNumber}
|
|
viewportSize={viewportSize}
|
|
zoom={zoom}
|
|
active={isPageInRenderWindow(pageNumber, currentPage, pages)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : pageError ? (
|
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
|
<div className="reader-fallback reader-fallback-error">
|
|
<span>{pageError.detail}</span>
|
|
<button className="ghost-button" onClick={() => setPageAttempt((value) => value + 1)}>
|
|
Reessayer
|
|
</button>
|
|
<details>
|
|
<summary>Détail technique</summary>
|
|
<pre>{pageError.technicalDetail}</pre>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
|
{(!viewportSize || loadingPage || !pageRendered) && (
|
|
<div className="reader-fallback">
|
|
<span>Page {currentPage}</span>
|
|
</div>
|
|
)}
|
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|