merge: fix/37-reader-webtoon-cbz-crash dans develop (error boundary lecteur — crash CBZ/CBR vertical contenu)
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/src/pages/ReaderPageCrash.test.ts
Normal file
20
apps/web/src/pages/ReaderPageCrash.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ReaderPage crash containment", () => {
|
||||
it("keeps a reader shell or fallback mounted when the CBZ vertical reader crashes", () => {
|
||||
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).toContain("<ReaderShell");
|
||||
expect(source).toMatch(/Reader(ErrorBoundary|CrashBoundary)|componentDidCatch|fallbackRender|onError/);
|
||||
});
|
||||
|
||||
it("keeps explicit recovery actions in the reader crash fallback", () => {
|
||||
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).toContain("Le lecteur a rencontré une erreur.");
|
||||
expect(source).toContain("Réessayer");
|
||||
expect(source).toContain("Revenir au mode page par page");
|
||||
expect(source).toContain("Retour à la fiche");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user