fix(web): lecteurs EPUB/PDF — gestion d'erreurs, worker pdf.js local

Composant ReaderError avec diagnostic et actions de repli, worker
pdf.js servi localement (pdfWorker) pour éviter les CDN, typage du
view foliate, navigation retour vers la fiche livre et styles lecteur
associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 16:24:02 +02:00
parent f3c6da2889
commit 4aacac12da
8 changed files with 316 additions and 50 deletions

View File

@ -41,6 +41,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number) => {
setPage(nextPage);
@ -61,7 +62,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
return (
<div className="reader-page">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
<button className="ghost-button" onClick={() => navigate(backHref)}>
<ArrowLeft size={17} />
Fiche
</button>
@ -87,11 +88,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
) : book.format === "cbz" || book.format === "cbr" ? (
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
) : (
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
)}
</div>
);

View File

@ -1,43 +1,145 @@
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { ReaderError, readerErrorMessage } from "./ReaderError";
type FoliateModule = {
EPUB?: unknown;
default?: unknown;
type FoliateLocation = {
cfi?: string;
fraction?: number;
current?: number;
total?: number;
};
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
type FoliateView = HTMLElement & {
open(input: File | Blob | string): Promise<void>;
close(): void;
goLeft(): Promise<void>;
goRight(): Promise<void>;
goTo(target: string): Promise<unknown>;
next(): Promise<void>;
lastLocation?: FoliateLocation;
};
export function epubFileName(url: string): string {
try {
const base = globalThis.location?.href ?? "http://readabook.local/";
const pathname = new URL(url, base).pathname;
const name = pathname.split("/").filter(Boolean).at(-1);
return name && name.includes(".") ? name : "book.epub";
} catch {
return "book.epub";
}
}
function locationPercent(location: FoliateLocation): number {
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
return Math.max(0, Math.min(100, (location.current / location.total) * 100));
}
return 1;
}
export function EpubReader({
url,
locator,
backHref,
onLocatorChange
}: {
url: string;
locator?: string;
backHref: string;
onLocatorChange: (locator: string, percent: number) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [frameKey, setFrameKey] = useState(0);
const [status, setStatus] = useState("Ouverture EPUB");
const viewRef = useRef<FoliateView | null>(null);
const locatorRef = useRef(locator);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
useEffect(() => {
let cancelled = false;
let view: FoliateView | null = null;
async function mount() {
try {
const module = (await import("foliate-js/epub.js")) as FoliateModule;
setLoading(true);
setError(undefined);
await import("foliate-js/view.js");
if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret");
} catch {
setStatus("Apercu EPUB indisponible dans ce navigateur");
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const blob = await response.blob();
if (cancelled || !hostRef.current) return;
view = document.createElement("foliate-view") as FoliateView;
view.classList.add("epub-view");
view.addEventListener("relocate", (event) => {
const location = (event as CustomEvent<FoliateLocation>).detail;
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
});
hostRef.current.replaceChildren(view);
viewRef.current = view;
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
await view.open(file);
if (cancelled) return;
if (locatorRef.current) await view.goTo(locatorRef.current);
else await view.next();
setLoading(false);
} catch (mountError) {
if (!cancelled) {
setError(readerErrorMessage(mountError, "EPUB indisponible"));
setLoading(false);
}
}
mount();
}
void mount();
return () => {
cancelled = true;
view?.close?.();
view?.remove();
if (viewRef.current === view) viewRef.current = null;
};
}, [url]);
}, [attempt, onLocatorChange, url]);
if (error) {
return (
<div className="epub-reader">
<ReaderError
title="Lecture EPUB 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)}
/>
</div>
);
}
return (
<div className="epub-reader" ref={hostRef}>
<iframe key={frameKey} title="EPUB" src={url} />
<div className="epub-reader">
{loading && (
<div className="reader-fallback">
<span>{status}</span>
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
Marquer la position
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
<div className="reader-stepper">
<button className="ghost-button" onClick={() => void viewRef.current?.goLeft()} disabled={loading}>
<ArrowLeft size={16} />
Précédent
</button>
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}>
Recharger
<span>{loading ? "Chargement" : "Lecture intégrée"}</span>
<button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
Suivant
<ArrowRight size={16} />
</button>
</div>
</div>

View File

@ -1,21 +1,36 @@
import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import { pdfWorkerSrc } from "./pdfWorker";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
export function PdfReader({
url,
page,
backHref,
onPageCommit
}: {
url: string;
page: number;
backHref: string;
onPageCommit: (page: number, pages: number) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1);
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
let renderTask: pdfjs.RenderTask | undefined;
async function render() {
try {
setLoading(true);
setError(undefined);
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
if (cancelled) return;
setPages(document.numPages);
@ -27,37 +42,53 @@ export function PdfReader({ url, page, onPageCommit }: { url: string; page: numb
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) return;
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
await renderTask.promise;
if (!cancelled) setLoading(false);
} catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
if (!cancelled) {
setError(readerErrorMessage(renderError, "PDF indisponible"));
setLoading(false);
}
}
render();
}
void render();
return () => {
cancelled = true;
renderTask?.cancel();
void loadingTask?.destroy();
};
}, [url, page, attempt]);
return (
<div className="pdf-reader">
{error ? (
<div className="reader-fallback">
<span>{error}</span>
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}>
Reessayer
</button>
</div>
<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 className="reader-stepper">
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}>
Precedent
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
Précédent
</button>
<span>
{page} / {pages}
</span>
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)}>
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
Suivant
</button>
</div>

View File

@ -0,0 +1,53 @@
import { ArrowLeft, Download, RotateCcw } from "lucide-react";
import { navigate } from "../router";
export function readerErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === "string" && error.trim()) return error;
return fallback;
}
export function ReaderError({
title,
detail,
technicalDetail,
downloadUrl,
backHref,
onRetry
}: {
title: string;
detail: string;
technicalDetail?: string;
downloadUrl: string;
backHref: string;
onRetry: () => void;
}) {
return (
<div className="reader-error">
<div>
<h2>{title}</h2>
<p>{detail}</p>
</div>
<div className="reader-error-actions">
<button className="ghost-button" onClick={onRetry}>
<RotateCcw size={16} />
Réessayer
</button>
<button className="ghost-button" onClick={() => navigate(backHref)}>
<ArrowLeft size={16} />
Retour à la fiche
</button>
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
</div>
{technicalDetail && (
<details>
<summary>Détail technique</summary>
<pre>{technicalDetail}</pre>
</details>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfWorkerSrc } from "./pdfWorker";
import { readerErrorMessage } from "./ReaderError";
describe("reader runtime helpers", () => {
it("extracts an EPUB file name from the file URL", () => {
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
});
it("keeps PDF.js worker source on the bundled module worker", () => {
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
});
it("normalizes reader technical errors", () => {
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
});
});

View File

@ -676,8 +676,23 @@ select {
min-height: calc(100vh - 120px);
}
.epub-host {
display: grid;
width: min(100%, 980px);
height: calc(100vh - 170px);
min-height: 460px;
}
.epub-view {
width: 100%;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #f7f0df;
color: #17110d;
}
.pdf-reader canvas,
.epub-reader iframe,
.cbz-reader img {
max-width: min(100%, 980px);
max-height: calc(100vh - 170px);
@ -691,11 +706,6 @@ select {
object-fit: contain;
}
.epub-reader iframe {
width: min(100%, 980px);
height: calc(100vh - 170px);
}
.reader-fallback {
display: flex;
flex-wrap: wrap;
@ -712,6 +722,46 @@ select {
gap: 12px;
}
.reader-error {
display: grid;
gap: 14px;
width: min(100%, 620px);
padding: 18px;
border: 1px solid rgba(169, 72, 52, 0.72);
border-radius: var(--radius);
background: rgba(169, 72, 52, 0.14);
}
.reader-error p {
margin-bottom: 0;
color: var(--ink-muted);
}
.reader-error-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reader-error a {
text-decoration: none;
}
.reader-error details {
color: var(--ink-muted);
}
.reader-error summary {
cursor: pointer;
}
.reader-error pre {
overflow: auto;
max-width: 100%;
margin: 10px 0 0;
white-space: pre-wrap;
}
@keyframes spin {
to {
transform: rotate(360deg);
@ -786,18 +836,21 @@ select {
.provider-row,
.provider-local,
.schedule-controls,
.save-bar {
.save-bar,
.reader-error-actions {
grid-template-columns: 1fr;
}
.save-bar,
.save-bar div,
.provider-actions {
.provider-actions,
.reader-error-actions {
justify-content: stretch;
}
.save-bar div,
.provider-actions {
.provider-actions,
.reader-error-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}

View File

@ -5,3 +5,8 @@ declare module "foliate-js/epub.js" {
export default module;
export const EPUB: unknown;
}
declare module "foliate-js/view.js" {
const module: unknown;
export default module;
}