fix(web): lecteur CBZ/CBR mode vertical — crash React contenu par error boundary avec actions de récupération (régression #37)

This commit is contained in:
Git Agent
2026-08-24 16:19:52 +02:00
parent c31d76a2a9
commit b5ec6a8e97
2 changed files with 137 additions and 37 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { CbzReader } from "../reader/CbzReader";
@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { navigate } from "../router";
const idleControls: ReaderControls = {
canPrevious: false,
@ -19,6 +20,44 @@ const idleControls: ReaderControls = {
onNext: () => undefined
};
type ReaderCrashBoundaryProps = {
resetKey: string;
onError: (error: Error) => void;
fallbackRender: (error: Error, retry: () => void) => ReactNode;
children: ReactNode;
};
type ReaderCrashBoundaryState = {
error: Error | null;
};
class ReaderCrashBoundary extends Component<ReaderCrashBoundaryProps, ReaderCrashBoundaryState> {
state: ReaderCrashBoundaryState = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
this.props.onError(error);
}
componentDidUpdate(previousProps: ReaderCrashBoundaryProps) {
if (previousProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
retry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) return this.props.fallbackRender(this.state.error, this.retry);
return this.props.children;
}
}
export function ReaderPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [loading, setLoading] = useState(true);
@ -114,6 +153,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
},
[currentVisiblePage, page, saveReaderMode]
);
const returnToPagedMode = useCallback(() => {
setPage(currentVisiblePage() ?? page);
setMode("horizontal");
saveReaderMode("horizontal");
}, [currentVisiblePage, page, saveReaderMode]);
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
@ -162,42 +206,78 @@ export function ReaderPage({ bookId }: { bookId: number }) {
zoomControls={zoomControls}
modeControls={modeControls}
>
{!book ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader
url={fileUrl}
page={page}
backHref={backHref}
zoom={zoom}
mode={mode}
onPageCommit={savePdfPage}
onControlsChange={setReaderControls}
/>
) : book.format === "cbz" || book.format === "cbr" ? (
<CbzReader
bookId={book.id}
page={page}
zoom={zoom}
mode={mode}
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
<EpubReader
url={fileUrl}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
)}
<ReaderCrashBoundary
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
onError={() => setReaderControls(idleControls)}
fallbackRender={(crashError, retry) => (
<div className="reader-error" role="alert">
<div>
<h2>Le lecteur a rencontré une erreur.</h2>
<p>La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.</p>
</div>
<div className="reader-error-actions">
<button className="ghost-button" onClick={retry}>
Réessayer
</button>
<button
className="ghost-button"
onClick={() => {
returnToPagedMode();
retry();
}}
>
Revenir au mode page par page
</button>
<button className="ghost-button" onClick={() => navigate(backHref)}>
Retour à la fiche
</button>
</div>
{crashError.message && (
<details>
<summary>Détail technique</summary>
<pre>{crashError.message}</pre>
</details>
)}
</div>
)}
>
{!book ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader
url={fileUrl}
page={page}
backHref={backHref}
zoom={zoom}
mode={mode}
onPageCommit={savePdfPage}
onControlsChange={setReaderControls}
/>
) : book.format === "cbz" || book.format === "cbr" ? (
<CbzReader
bookId={book.id}
page={page}
zoom={zoom}
mode={mode}
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
<EpubReader
url={fileUrl}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
)}
</ReaderCrashBoundary>
</ReaderShell>
);
}