Files
ReadaBook/apps/web/src/reader/PdfReader.tsx
Git Agent 5de46a6f6d fix(web,api): lecteur — worker pdf.js dédié, shell commun et préférences par livre
Régression worker PDF : le worker pdf.js est désormais instancié une
seule fois via un port dédié (?worker&url) et reconfiguré à chaque
montage, au lieu d'un workerSrc recalculé qui cassait le rendu.

- ReaderShell : chrome commun aux lecteurs (toolbar, zones de tap,
  statut) et contrat ReaderControls pour EPUB/PDF/CBZ
- préférences de lecture par livre (mode horizontal/vertical, fit) :
  table reader_preferences + migrations idempotentes, module API,
  DTO partagés, client web avec fallback localStorage hors-ligne

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 18:06:22 +02:00

130 lines
4.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import { configurePdfWorker } from "./pdfWorker";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
configurePdfWorker(pdfjs);
export function PdfReader({
url,
page,
backHref,
mode,
onPageCommit,
onControlsChange
}: {
url: string;
page: number;
backHref: string;
mode: ReaderMode;
onPageCommit: (page: number, pages: number) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const [pages, setPages] = useState(1);
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 go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]);
useEffect(() => {
onControlsChange({
canPrevious: !loading && !error && currentPage > 1,
canNext: !loading && !error && currentPage < pages,
positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`,
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, error, go, 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;
let renderTask: pdfjs.RenderTask | undefined;
async function render() {
try {
configurePdfWorker(pdfjs);
setLoading(true);
setError(undefined);
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
if (cancelled) return;
setPages(document.numPages);
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
const canvas = canvasRef.current;
if (!canvas) return;
const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale =
mode === "vertical"
? frameSize.width / baseViewport.width
: Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height);
const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1);
const viewport = pdfPage.getViewport({ scale: renderScale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`;
canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`;
const context = canvas.getContext("2d");
if (!context) return;
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
await renderTask.promise;
if (!cancelled) setLoading(false);
} catch (renderError) {
if (!cancelled) {
setError(readerErrorMessage(renderError, "PDF indisponible"));
setLoading(false);
}
}
}
void render();
return () => {
cancelled = true;
renderTask?.cancel();
void loadingTask?.destroy();
};
}, [url, page, attempt, frameSize.height, frameSize.width, mode]);
return (
<div className="pdf-reader" ref={frameRef}>
{error ? (
<ReaderError
title="Lecture PDF indisponible"
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
technicalDetail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
) : (
<>
{loading && (
<div className="reader-fallback">
<span>Ouverture PDF</span>
</div>
)}
<canvas ref={canvasRef} />
</>
)}
</div>
);
}