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

View File

@ -1,43 +1,145 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { ReaderError, readerErrorMessage } from "./ReaderError";
type FoliateModule = { type FoliateLocation = {
EPUB?: unknown; cfi?: string;
default?: unknown; 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 hostRef = useRef<HTMLDivElement>(null);
const [frameKey, setFrameKey] = useState(0); const viewRef = useRef<FoliateView | null>(null);
const [status, setStatus] = useState("Ouverture EPUB"); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let view: FoliateView | null = null;
async function mount() { async function mount() {
try { 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; if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret"); const response = await fetch(url, { credentials: "include" });
} catch { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
setStatus("Apercu EPUB indisponible dans ce navigateur"); 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 () => { return () => {
cancelled = true; 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 ( return (
<div className="epub-reader" ref={hostRef}> <div className="epub-reader">
<iframe key={frameKey} title="EPUB" src={url} /> {loading && (
<div className="reader-fallback"> <div className="reader-fallback">
<span>{status}</span> <span>Ouverture EPUB</span>
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}> </div>
Marquer la position )}
<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>
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}> <span>{loading ? "Chargement" : "Lecture intégrée"}</span>
Recharger <button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
Suivant
<ArrowRight size={16} />
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,21 +1,36 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist"; 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 canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1); const [pages, setPages] = useState(1);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
let renderTask: pdfjs.RenderTask | undefined;
async function render() { async function render() {
try { try {
setLoading(true);
setError(undefined); setError(undefined);
const loadingTask = pdfjs.getDocument({ url, withCredentials: true }); loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise; const document = await loadingTask.promise;
if (cancelled) return; if (cancelled) return;
setPages(document.numPages); setPages(document.numPages);
@ -27,37 +42,53 @@ export function PdfReader({ url, page, onPageCommit }: { url: string; page: numb
canvas.height = viewport.height; canvas.height = viewport.height;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!context) return; 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) { } catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "PDF indisponible"); if (!cancelled) {
setError(readerErrorMessage(renderError, "PDF indisponible"));
setLoading(false);
} }
} }
render(); }
void render();
return () => { return () => {
cancelled = true; cancelled = true;
renderTask?.cancel();
void loadingTask?.destroy();
}; };
}, [url, page, attempt]); }, [url, page, attempt]);
return ( return (
<div className="pdf-reader"> <div className="pdf-reader">
{error ? ( {error ? (
<div className="reader-fallback"> <ReaderError
<span>{error}</span> title="Lecture PDF indisponible"
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}> detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
Reessayer technicalDetail={error}
</button> downloadUrl={url}
</div> backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
) : ( ) : (
<>
{loading && (
<div className="reader-fallback">
<span>Ouverture PDF</span>
</div>
)}
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
</>
)} )}
<div className="reader-stepper"> <div className="reader-stepper">
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}> <button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
Precedent Précédent
</button> </button>
<span> <span>
{page} / {pages} {page} / {pages}
</span> </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 Suivant
</button> </button>
</div> </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); 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, .pdf-reader canvas,
.epub-reader iframe,
.cbz-reader img { .cbz-reader img {
max-width: min(100%, 980px); max-width: min(100%, 980px);
max-height: calc(100vh - 170px); max-height: calc(100vh - 170px);
@ -691,11 +706,6 @@ select {
object-fit: contain; object-fit: contain;
} }
.epub-reader iframe {
width: min(100%, 980px);
height: calc(100vh - 170px);
}
.reader-fallback { .reader-fallback {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -712,6 +722,46 @@ select {
gap: 12px; 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 { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -786,18 +836,21 @@ select {
.provider-row, .provider-row,
.provider-local, .provider-local,
.schedule-controls, .schedule-controls,
.save-bar { .save-bar,
.reader-error-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.save-bar, .save-bar,
.save-bar div, .save-bar div,
.provider-actions { .provider-actions,
.reader-error-actions {
justify-content: stretch; justify-content: stretch;
} }
.save-bar div, .save-bar div,
.provider-actions { .provider-actions,
.reader-error-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }

View File

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