Archived
251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
/**
|
||
* PdfReadingView — full-screen PDF reading powered by PDF.js.
|
||
*/
|
||
|
||
import { lazy, 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 { usePdfReader } from "../../hooks/usePdfReader";
|
||
import { useReadingSettings } from "../../hooks/useReadingSettings";
|
||
import { useReaderOrientationCss } from "../../hooks/useReaderOrientationCss";
|
||
import { ReaderErrorScreen } from "./ReaderErrorScreen";
|
||
import { ReaderLoadingScreen } from "./ReaderLoadingScreen";
|
||
import { ReaderSuspenseShell } from "./ReaderSuspenseShell";
|
||
import { useToast } from "../../hooks/useToast";
|
||
import { booksApi } from "../../api/books";
|
||
import type { EBookDetail } from "../../types/book";
|
||
import type { EpubTocItem } from "./TableOfContents";
|
||
import { parsePageHref, serializePdfAnchor } from "../../utils/pdfAnchor";
|
||
import { fallbackPdfPageToc } from "../../utils/pdfToc";
|
||
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 { showToast } = useToast();
|
||
const { markers, addMarker, loadBookmarks } = useAnnotations();
|
||
|
||
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,
|
||
goToPage,
|
||
goToNextPage,
|
||
goToPrevPage,
|
||
beginBookmarkPeek,
|
||
resumeReadingAnchor,
|
||
goToAnchor,
|
||
} = usePdfReader(bookId, book.page_count, initialAnchor);
|
||
|
||
const bookMarkers = useMemo(
|
||
() => markers.filter((m) => m.ebook_id === bookId),
|
||
[markers, bookId],
|
||
);
|
||
|
||
const railItems = useBookmarkRailLayout(bookMarkers);
|
||
|
||
useEffect(() => {
|
||
if (!bookId || Number.isNaN(bookId)) return;
|
||
void loadBookmarks(bookId);
|
||
}, [bookId, loadBookmarks]);
|
||
|
||
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]);
|
||
|
||
useReaderOrientationCss(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 handleBookmarkPage = useCallback(() => {
|
||
const anchor = serializePdfAnchor(currentPage);
|
||
const exists = bookMarkers.some((m) => m.epub_cfi === anchor);
|
||
if (exists) {
|
||
showToast({ message: t("reader.pageAlreadyBookmarked"), variant: "warning" });
|
||
return;
|
||
}
|
||
void addMarker({
|
||
ebook: bookId,
|
||
epub_cfi: anchor,
|
||
chapter_index: currentPage - 1,
|
||
chapter_title: t("reader.pdfPageLabel", { page: String(currentPage) }),
|
||
location_text: "",
|
||
content: "",
|
||
})
|
||
.then(() => {
|
||
showToast({ message: t("reader.pageBookmarked"), variant: "success" });
|
||
})
|
||
.catch(() => {
|
||
showToast({ message: t("annotations.saveFailed"), variant: "error" });
|
||
});
|
||
}, [addMarker, bookId, bookMarkers, currentPage, showToast, t]);
|
||
|
||
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]);
|
||
|
||
const isCurrentPageBookmarked = useMemo(() => {
|
||
const anchor = serializePdfAnchor(currentPage);
|
||
return bookMarkers.some((m) => m.epub_cfi === anchor);
|
||
}, [bookMarkers, currentPage]);
|
||
|
||
if (isLoading) {
|
||
return <ReaderLoadingScreen />;
|
||
}
|
||
|
||
if (error || !pdfDocument) {
|
||
const message =
|
||
error === "PDF_PASSWORD" ? t("reader.pdfPassword") : (error ?? t("reader.unableToOpen"));
|
||
return <ReaderErrorScreen message={message} onBack={() => navigate("/")} />;
|
||
}
|
||
|
||
return (
|
||
<ReaderSuspenseShell 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)}
|
||
onBookmarkPage={handleBookmarkPage}
|
||
isCurrentPageBookmarked={isCurrentPageBookmarked}
|
||
/>
|
||
|
||
<PdfLimitationsNotice bookId={bookId} />
|
||
|
||
<TableOfContents
|
||
items={tocItems}
|
||
isOpen={tocOpen}
|
||
onClose={() => setTocOpen(false)}
|
||
onNavigate={handleTocNavigate}
|
||
/>
|
||
|
||
<BookMarkersPanel
|
||
ebookId={bookId}
|
||
isOpen={markersOpen}
|
||
onClose={() => setMarkersOpen(false)}
|
||
onGoToPassage={handleGoToMarker}
|
||
emptyHintKey="annotations.pdfPageBookmarkHint"
|
||
/>
|
||
|
||
<ReadingSettingsPanel
|
||
format="pdf"
|
||
settings={settings}
|
||
pdfScale={scale}
|
||
onPdfScaleChange={setScale}
|
||
isOpen={settingsOpen}
|
||
onClose={() => setSettingsOpen(false)}
|
||
onUpdate={updateSettings}
|
||
onFlush={flushSettings}
|
||
/>
|
||
|
||
<main className="reader-pdf-container">
|
||
<div className="reader-pdf-scroll">
|
||
<PdfViewer
|
||
document={pdfDocument}
|
||
pageNumber={currentPage}
|
||
scale={scale}
|
||
/>
|
||
</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>
|
||
</ReaderSuspenseShell>
|
||
);
|
||
}
|