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>
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
|
import type { ReaderControls } from "./ReaderShell";
|
|
import type { ReaderMode } from "../api/types";
|
|
|
|
type FoliateLocation = {
|
|
cfi?: string;
|
|
fraction?: number;
|
|
current?: number;
|
|
total?: number;
|
|
};
|
|
|
|
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,
|
|
mode,
|
|
onLocatorChange,
|
|
onControlsChange
|
|
}: {
|
|
url: string;
|
|
locator?: string;
|
|
backHref: string;
|
|
mode: ReaderMode;
|
|
onLocatorChange: (locator: string, percent: number) => void;
|
|
onControlsChange: (controls: ReaderControls) => void;
|
|
}) {
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
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(() => {
|
|
onControlsChange({
|
|
canPrevious: !loading && !error,
|
|
canNext: !loading && !error,
|
|
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
|
|
onPrevious: () => void viewRef.current?.goLeft(),
|
|
onNext: () => void viewRef.current?.goRight()
|
|
});
|
|
}, [error, loading, onControlsChange]);
|
|
|
|
useEffect(() => {
|
|
locatorRef.current = locator;
|
|
}, [locator]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let view: FoliateView | null = null;
|
|
|
|
async function mount() {
|
|
try {
|
|
setLoading(true);
|
|
setError(undefined);
|
|
await import("foliate-js/view.js");
|
|
if (cancelled || !hostRef.current) return;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
void mount();
|
|
return () => {
|
|
cancelled = true;
|
|
view?.close?.();
|
|
view?.remove();
|
|
if (viewRef.current === view) viewRef.current = null;
|
|
};
|
|
}, [attempt, onLocatorChange, url]);
|
|
|
|
useEffect(() => {
|
|
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
|
viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal");
|
|
}, [mode]);
|
|
|
|
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 epub-reader-${mode}`}>
|
|
{loading && (
|
|
<div className="reader-fallback">
|
|
<span>Ouverture EPUB</span>
|
|
</div>
|
|
)}
|
|
<div className="epub-host" ref={hostRef} />
|
|
</div>
|
|
);
|
|
}
|