285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
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";
|
|
import { EpubReader } from "../reader/EpubReader";
|
|
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
|
import { PdfReader } from "../reader/PdfReader";
|
|
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomControls } from "../reader/ReaderShell";
|
|
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
|
|
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,
|
|
canNext: false,
|
|
positionLabel: "Chargement",
|
|
onPrevious: () => 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 }) {
|
|
const [book, setBook] = useState<BookDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string>();
|
|
const [page, setPage] = useState(1);
|
|
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
|
|
const [mode, setMode] = useState<ReaderMode>("horizontal");
|
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
|
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
|
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
|
|
|
async function loadBook() {
|
|
setLoading(true);
|
|
setError(undefined);
|
|
try {
|
|
setBook(await api.book(bookId));
|
|
} catch (loadError) {
|
|
const fallback = getApiFallback<BookDto>(loadError);
|
|
setBook(fallback ?? null);
|
|
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadBook();
|
|
}, [bookId]);
|
|
|
|
useEffect(() => {
|
|
setReaderControls(idleControls);
|
|
setPage(1);
|
|
setZoom(READER_ZOOM_DEFAULT);
|
|
setMode("horizontal");
|
|
}, [bookId]);
|
|
|
|
useEffect(() => {
|
|
if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode);
|
|
else setMode("horizontal");
|
|
}, [book?.format, preferences.mode]);
|
|
|
|
useEffect(() => {
|
|
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
|
}, [progress]);
|
|
|
|
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
|
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
|
const savePdfPage = useCallback(
|
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
|
setPage(nextPage);
|
|
const locator = pageLocator("pdf", nextPage, anchor);
|
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
|
if (strategy === "queued") queueSave(locator, percent);
|
|
else void save(locator, percent);
|
|
},
|
|
[queueSave, save]
|
|
);
|
|
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
|
const saveComicPage = useCallback(
|
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
|
setPage(nextPage);
|
|
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
|
const locator = pageLocator(prefix, nextPage, anchor);
|
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
|
if (strategy === "queued") queueSave(locator, percent);
|
|
else void save(locator, percent);
|
|
},
|
|
[book?.format, queueSave, save]
|
|
);
|
|
|
|
const readerError = error ?? progressError ?? preferencesError;
|
|
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
|
const supportsMode = supportsZoom;
|
|
const currentVisiblePage = useCallback(() => {
|
|
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
|
if (!stage) return null;
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const pages = Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]"))
|
|
.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const pageNumber = Number(element.dataset.readerPage);
|
|
return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null;
|
|
})
|
|
.filter((item): item is { page: number; top: number; bottom: number } => Boolean(item));
|
|
return majorityVisiblePage(pages, stageRect.top, stageRect.bottom);
|
|
}, []);
|
|
const changeMode = useCallback(
|
|
(nextMode: ReaderMode) => {
|
|
const anchorPage = currentVisiblePage() ?? page;
|
|
setPage(anchorPage);
|
|
setMode(nextMode);
|
|
saveReaderMode(nextMode);
|
|
},
|
|
[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;
|
|
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? (stage.scrollTop + stage.clientHeight / 2) / stage.scrollHeight : 0.5;
|
|
|
|
setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom));
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (!stage) return;
|
|
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
|
|
stage.scrollTop = Math.max(0, stage.scrollHeight * scrollRatioY - stage.clientHeight / 2);
|
|
});
|
|
});
|
|
}, []);
|
|
const zoomControls = useMemo<ReaderZoomControls | undefined>(
|
|
() =>
|
|
supportsZoom
|
|
? {
|
|
zoom,
|
|
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
|
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
|
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
|
}
|
|
: undefined,
|
|
[changeZoom, supportsZoom, zoom]
|
|
);
|
|
const modeControls = useMemo<ReaderModeControls | undefined>(
|
|
() =>
|
|
supportsMode
|
|
? {
|
|
mode,
|
|
onModeChange: changeMode
|
|
}
|
|
: undefined,
|
|
[changeMode, mode, supportsMode]
|
|
);
|
|
|
|
return (
|
|
<ReaderShell
|
|
title={book?.title ?? "Ouverture du lecteur"}
|
|
backHref={backHref}
|
|
error={readerError}
|
|
onRetry={error ? () => void loadBook() : undefined}
|
|
controls={readerControls}
|
|
zoomControls={zoomControls}
|
|
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 ? (
|
|
<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>
|
|
);
|
|
}
|