Files
ReadaBook/apps/web/src/reader/PdfReader.tsx
Git Agent 84e789acc4 fix(web): lecteur PDF/CBZ — pages blanches, timeouts et modes fullscreen
- Rendu PDF via module dédié pdfRender (worker), correction des pages
  blanches et gestion des timeouts de rendu
- Lecteur comics : crop plein écran, locators de page unifiés (pdf/cbz/cbr)
  avec ancrage pour le calcul de progression
- Sauvegarde de progression mise en file (queueSave) pour éviter les
  pertes lors des navigations rapides
- nginx/vite : en-têtes et proxy alignés pour le streaming des livres

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:11:25 +02:00

285 lines
10 KiB
TypeScript

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<T>(work: () => Promise<T>) {
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<HTMLCanvasElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div className="pdf-page-frame" data-reader-page={pageNumber} ref={wrapperRef}>
{!rendered && (
<div className="reader-fallback">
<span>Page {pageNumber}</span>
</div>
)}
<canvas ref={canvasRef} />
</div>
);
}
export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState(1);
const [visiblePage, setVisiblePage] = useState(page);
const [error, setError] = useState<string>();
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<HTMLElement>("[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 (
<div className={`pdf-reader pdf-reader-${mode}`} ref={frameRef}>
{error ? (
<ReaderError
title="Lecture PDF indisponible"
detail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
) : loading || !documentProxy ? (
<div className="reader-fallback">
<span>Ouverture PDF</span>
</div>
) : mode === "vertical" ? (
<div className="pdf-strip">
{Array.from({ length: pages }, (_, index) => (
<PdfPageCanvas key={index + 1} document={documentProxy} pageNumber={index + 1} mode={mode} frameSize={frameSize} onError={handlePageError} />
))}
</div>
) : (
<PdfPageCanvas document={documentProxy} pageNumber={currentPage} mode={mode} frameSize={frameSize} onError={handlePageError} />
)}
</div>
);
}