feat: pdf reader

This commit is contained in:
2026-06-03 22:46:38 -05:00
parent d9c66e68a6
commit f091386974
29 changed files with 29469 additions and 2593 deletions
@@ -0,0 +1,260 @@
/**
* PdfReadingView — full-screen PDF reading powered by PDF.js.
*/
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import { useBookmarkRailLayout } from "../../hooks/useBookmarkRailLayout";
import { usePdfHighlights } from "../../hooks/usePdfHighlights";
import { usePdfReader } from "../../hooks/usePdfReader";
import { usePdfSelection } from "../../hooks/usePdfSelection";
import { useReadingSettings } from "../../hooks/useReadingSettings";
import { booksApi } from "../../api/books";
import type { EBookDetail } from "../../types/book";
import type { EpubTocItem } from "./TableOfContents";
import { parsePageHref } from "../../utils/pdfAnchor";
import { fallbackPdfPageToc } from "../../utils/pdfToc";
import { SelectionPopover } from "./SelectionPopover";
import { BookMarkersPanel } from "./BookMarkersPanel";
import { BookmarkReaderRail } from "./BookmarkReaderRail";
import { ResumeReadingButton } from "./ResumeReadingButton";
import { PdfLimitationsNotice } from "./PdfLimitationsNotice";
import { PdfViewer } from "./PdfViewer";
import type { MarkerEntry } from "@/types";
const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
const TableOfContents = lazy(() => import("./TableOfContents"));
const ReadingSettingsPanel = lazy(() => import("./ReadingSettingsPanel"));
interface PdfReadingViewProps {
book: EBookDetail;
bookId: number;
initialAnchor?: string;
}
export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [tocOpen, setTocOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [markersOpen, setMarkersOpen] = useState(false);
const [tocItems, setTocItems] = useState<EpubTocItem[]>([]);
const { settings, updateSettings, flushSettings } = useReadingSettings();
const {
pdfDocument,
currentPage,
pageCount,
scale,
setScale,
chapterTitle,
progress,
isLoading,
error,
isBookmarkPeekActive,
readingAnchor,
pageWrapRef,
goToPage,
goToNextPage,
goToPrevPage,
beginBookmarkPeek,
resumeReadingAnchor,
goToAnchor,
} = usePdfReader(bookId, book.page_count, initialAnchor);
const { pendingSelection, clearSelection } = usePdfSelection(
pageWrapRef,
currentPage,
chapterTitle,
);
const { markers } = useAnnotations();
const bookMarkers = useMemo(
() => markers.filter((m) => m.ebook_id === bookId),
[markers, bookId],
);
const pdfHighlights = usePdfHighlights(bookId, currentPage);
const railItems = useBookmarkRailLayout(bookMarkers);
useEffect(() => {
void booksApi
.getToc(bookId)
.then((res) => {
const items = res.chapters.map((ch) => ({
label: ch.title,
href: ch.href || `pdf:page:${ch.index + 1}`,
}));
setTocItems(
items.length > 0
? items
: fallbackPdfPageToc(pageCount, (p) => t("reader.pdfPageLabel", { page: String(p) })),
);
})
.catch(() =>
setTocItems(fallbackPdfPageToc(pageCount, (p) => t("reader.pdfPageLabel", { page: String(p) }))),
);
}, [bookId, pageCount, t]);
useEffect(() => {
const root = document.documentElement;
if (settings.orientation_lock !== "auto") {
root.style.setProperty(
"--reader-orientation",
settings.orientation_lock === "portrait" ? "portrait" : "landscape",
);
} else {
root.style.removeProperty("--reader-orientation");
}
}, [settings.orientation_lock]);
const handleTocNavigate = useCallback(
(href: string) => {
const page = parsePageHref(href);
if (page) goToPage(page);
else goToAnchor(href);
},
[goToAnchor, goToPage],
);
const handleGoToMarker = useCallback(
(marker: MarkerEntry) => {
beginBookmarkPeek(marker.epub_cfi);
setMarkersOpen(false);
},
[beginBookmarkPeek],
);
const progressForToolbar = useMemo(() => {
if (!progress) return progress;
const pct = pageCount > 0 ? Math.round((currentPage / pageCount) * 100) : 0;
return {
...progress,
percentage: pct,
current_position: pct,
current_chapter: currentPage,
};
}, [progress, currentPage, pageCount]);
if (isLoading) {
return (
<div className="reader-loading">
<div className="spinner" />
<p>{t("reader.loading")}</p>
</div>
);
}
if (error || !pdfDocument) {
const message =
error === "PDF_PASSWORD" ? t("reader.pdfPassword") : (error ?? t("reader.unableToOpen"));
return (
<div className="reader-loading">
<p className="reader-error">{message}</p>
<button type="button" className="back-button" onClick={() => navigate("/")}>
{t("reader.backToLibrary")}
</button>
</div>
);
}
return (
<Suspense
fallback={
<div className="reader-loading">
<div className="spinner" />
</div>
}
>
<div className="reader-container" data-theme={settings.theme}>
<ReaderToolbar
bookTitle={book.title}
chapterTitle={chapterTitle || t("reader.reading")}
progress={progressForToolbar}
onBack={() => navigate("/")}
onToggleToc={() => setTocOpen((v) => !v)}
onToggleSettings={() => setSettingsOpen((v) => !v)}
onToggleMarkers={() => setMarkersOpen((v) => !v)}
/>
<PdfLimitationsNotice bookId={bookId} />
<TableOfContents
items={tocItems}
isOpen={tocOpen}
onClose={() => setTocOpen(false)}
onNavigate={handleTocNavigate}
/>
<BookMarkersPanel
ebookId={bookId}
isOpen={markersOpen}
onClose={() => setMarkersOpen(false)}
onGoToPassage={handleGoToMarker}
/>
<ReadingSettingsPanel
format="pdf"
settings={settings}
pdfScale={scale}
onPdfScaleChange={setScale}
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
onUpdate={updateSettings}
onFlush={flushSettings}
/>
{pendingSelection && (
<SelectionPopover
ebookId={bookId}
selection={pendingSelection}
onClose={clearSelection}
onSaved={clearSelection}
/>
)}
<main className="reader-pdf-container">
<div className="reader-pdf-scroll">
<PdfViewer
document={pdfDocument}
pageNumber={currentPage}
scale={scale}
highlights={pdfHighlights}
pageWrapRef={pageWrapRef}
/>
</div>
<BookmarkReaderRail
items={railItems}
onGoToBookmark={beginBookmarkPeek}
/>
<button
type="button"
className="reader-page-nav reader-page-nav--prev"
onClick={goToPrevPage}
disabled={currentPage <= 1}
aria-label={t("reader.prevPage")}
>
</button>
<ResumeReadingButton
visible={isBookmarkPeekActive && readingAnchor != null}
onResume={resumeReadingAnchor}
/>
<button
type="button"
className="reader-page-nav reader-page-nav--next"
onClick={goToNextPage}
disabled={currentPage >= pageCount}
aria-label={t("reader.nextPage")}
>
</button>
</main>
</div>
</Suspense>
);
}