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:
@ -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="reader-fallback">
|
||||
<span>{status}</span>
|
||||
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
|
||||
Marquer la position
|
||||
<div className="epub-reader">
|
||||
{loading && (
|
||||
<div className="reader-fallback">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user