Files
ReadaBook/apps/web/src/pages/ReaderPage.tsx

307 lines
11 KiB
TypeScript

import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared";
import { api } 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 ReaderZoomAnchor, 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";
import { EmptyState, LoadingState, Panel } from "../components/ui";
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 {
setBook(null);
setError("Ce livre n'existe pas dans le catalogue.");
} 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), anchor?: ReaderZoomAnchor) => {
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.scrollHeight - stage.clientHeight) : 0;
setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom));
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!stage) return;
if (anchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
return;
}
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
});
});
}, []);
const zoomControls = useMemo<ReaderZoomControls | undefined>(
() =>
supportsZoom
? {
zoom,
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
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]
);
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="reader-missing-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="reader-error-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
Réessayer
</button>
</div>
</Panel>
</section>
);
}
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.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>
);
}