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 type { BookDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
import { CbzReader } from "../reader/CbzReader";
|
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 { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
|
||||||
const idleControls: ReaderControls = {
|
const idleControls: ReaderControls = {
|
||||||
canPrevious: false,
|
canPrevious: false,
|
||||||
@ -19,6 +20,44 @@ const idleControls: ReaderControls = {
|
|||||||
onNext: () => undefined
|
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 }) {
|
export function ReaderPage({ bookId }: { bookId: number }) {
|
||||||
const [book, setBook] = useState<BookDto | null>(null);
|
const [book, setBook] = useState<BookDto | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -114,6 +153,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
},
|
},
|
||||||
[currentVisiblePage, page, saveReaderMode]
|
[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 changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number)) => {
|
||||||
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
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;
|
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
|
||||||
@ -161,6 +205,41 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
controls={readerControls}
|
controls={readerControls}
|
||||||
zoomControls={zoomControls}
|
zoomControls={zoomControls}
|
||||||
modeControls={modeControls}
|
modeControls={modeControls}
|
||||||
|
>
|
||||||
|
<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 ? (
|
{!book ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
@ -198,6 +277,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ReaderCrashBoundary>
|
||||||
</ReaderShell>
|
</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