Archived
feat: pdf reader
This commit is contained in:
@@ -91,13 +91,15 @@ class EBookListSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_started(self, obj):
|
def get_started(self, obj):
|
||||||
"""True when the user has opened the reader and has a saved EPUB location."""
|
"""True when the user has opened the reader with saved progress."""
|
||||||
try:
|
try:
|
||||||
rp = obj.reading_progress
|
rp = obj.reading_progress
|
||||||
except ReadingProgress.DoesNotExist:
|
except ReadingProgress.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
if rp.current_position >= 99:
|
if rp.current_position >= 99:
|
||||||
return False
|
return False
|
||||||
|
if obj.format == "pdf":
|
||||||
|
return rp.last_page > 0 or rp.current_position > 0
|
||||||
return bool((rp.epub_location or "").strip())
|
return bool((rp.epub_location or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,69 @@ def _process_epub(file_path: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _process_pdf(file_path: str) -> dict[str, Any]:
|
def _process_pdf(file_path: str) -> dict[str, Any]:
|
||||||
return {"format": "pdf", "page_count": 0, "metadata": {}, "toc": []}
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
reader = PdfReader(file_path)
|
||||||
|
page_count = len(reader.pages)
|
||||||
|
metadata = _extract_pdf_metadata(reader)
|
||||||
|
toc = _extract_pdf_outline(reader)
|
||||||
|
if not toc and page_count > 0:
|
||||||
|
toc = [
|
||||||
|
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
|
||||||
|
for i in range(1, page_count + 1)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"format": "pdf",
|
||||||
|
"page_count": page_count,
|
||||||
|
"metadata": metadata,
|
||||||
|
"toc": toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_metadata(reader) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
info = reader.metadata
|
||||||
|
if not info:
|
||||||
|
return metadata
|
||||||
|
title = getattr(info, "title", None)
|
||||||
|
author = getattr(info, "author", None)
|
||||||
|
if title:
|
||||||
|
metadata["title"] = str(title)
|
||||||
|
if author:
|
||||||
|
metadata["author"] = str(author)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
|
||||||
|
outline = getattr(reader, "outline", None)
|
||||||
|
if not outline:
|
||||||
|
return []
|
||||||
|
return _walk_pdf_outline(reader, outline)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(outline):
|
||||||
|
item = outline[i]
|
||||||
|
if isinstance(item, list):
|
||||||
|
if entries:
|
||||||
|
entries[-1]["children"] = _walk_pdf_outline(reader, item)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
title = getattr(item, "title", None) or "Section"
|
||||||
|
page_num = 1
|
||||||
|
try:
|
||||||
|
page_num = reader.get_destination_page_number(item) + 1
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve PDF outline destination", exc_info=True)
|
||||||
|
entries.append({
|
||||||
|
"title": str(title),
|
||||||
|
"href": f"pdf:page:{page_num}",
|
||||||
|
"children": [],
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _extract_epub_metadata(book) -> dict[str, Any]:
|
def _extract_epub_metadata(book) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -168,18 +168,23 @@ class EBookViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
@action(detail=True, methods=["get"])
|
||||||
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
||||||
"""Stream the EPUB file for authenticated in-browser reading."""
|
"""Stream the ebook file for authenticated in-browser reading."""
|
||||||
ebook = self.get_object()
|
ebook = self.get_object()
|
||||||
if ebook.format != "epub":
|
content_types = {
|
||||||
|
"epub": "application/epub+zip",
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ebook.format)
|
||||||
|
if not content_type:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Reader supports EPUB only."},
|
{"error": "Unsupported format for in-browser reading."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
if not ebook.file:
|
if not ebook.file:
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
ebook.file.open("rb"),
|
ebook.file.open("rb"),
|
||||||
content_type="application/epub+zip",
|
content_type=content_type,
|
||||||
filename=ebook.filename(),
|
filename=ebook.filename(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
@@ -21,4 +21,5 @@ dependencies = [
|
|||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"beautifulsoup4>=4.12.0",
|
"beautifulsoup4>=4.12.0",
|
||||||
|
"pypdf>=5.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+11
@@ -52,6 +52,7 @@ dependencies = [
|
|||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pypdf" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
{ name = "pytest-django" },
|
{ name = "pytest-django" },
|
||||||
@@ -74,6 +75,7 @@ requires-dist = [
|
|||||||
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
||||||
{ name = "pydantic", specifier = "==2.10.5" },
|
{ name = "pydantic", specifier = "==2.10.5" },
|
||||||
{ name = "pydantic-settings", specifier = "==2.7.1" },
|
{ name = "pydantic-settings", specifier = "==2.7.1" },
|
||||||
|
{ name = "pypdf", specifier = ">=5.0.0" },
|
||||||
{ name = "pytest", specifier = "==8.3.4" },
|
{ name = "pytest", specifier = "==8.3.4" },
|
||||||
{ name = "pytest-cov", specifier = "==6.0.0" },
|
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||||
{ name = "pytest-django", specifier = "==4.9.0" },
|
{ name = "pytest-django", specifier = "==4.9.0" },
|
||||||
@@ -567,6 +569,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdf"
|
||||||
|
version = "6.12.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "8.3.4"
|
version = "8.3.4"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18n-lite": "^1.0.10",
|
"react-i18n-lite": "^1.0.10",
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const booksApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getEbookFile(id: number): Promise<Blob> {
|
||||||
|
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async getEpubFile(id: number): Promise<Blob> {
|
async getEpubFile(id: number): Promise<Blob> {
|
||||||
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
|
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* EpubReadingView — full-screen EPUB reading powered by react-reader (epub.js).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18n-lite";
|
||||||
|
import { EpubView, EpubViewStyle } from "react-reader";
|
||||||
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||||
|
import { useBookmarkRailLayout } from "../../hooks/useBookmarkRailLayout";
|
||||||
|
import { useEpubHighlights } from "../../hooks/useEpubHighlights";
|
||||||
|
import { useEpubReader } from "../../hooks/useEpubReader";
|
||||||
|
import { useEpubSelection } from "../../hooks/useEpubSelection";
|
||||||
|
import { useReadingSettings } from "../../hooks/useReadingSettings";
|
||||||
|
import type { EBookDetail } from "../../types/book";
|
||||||
|
import type { EpubTocItem } from "./TableOfContents";
|
||||||
|
import { SelectionPopover } from "./SelectionPopover";
|
||||||
|
import { BookMarkersPanel } from "./BookMarkersPanel";
|
||||||
|
import { BookmarkReaderRail } from "./BookmarkReaderRail";
|
||||||
|
import { ResumeReadingButton } from "./ResumeReadingButton";
|
||||||
|
import type { MarkerEntry } from "@/types";
|
||||||
|
|
||||||
|
const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
|
||||||
|
const TableOfContents = lazy(() => import("./TableOfContents"));
|
||||||
|
const ReadingSettingsPanel = lazy(() => import("./ReadingSettingsPanel"));
|
||||||
|
|
||||||
|
interface EpubReadingViewProps {
|
||||||
|
book: EBookDetail;
|
||||||
|
bookId: number;
|
||||||
|
initialEpubLocation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EpubReadingView({ book, bookId, initialEpubLocation }: EpubReadingViewProps) {
|
||||||
|
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 [renditionVersion, setRenditionVersion] = useState(0);
|
||||||
|
|
||||||
|
const { settings, updateSettings, flushSettings } = useReadingSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
epubUrl,
|
||||||
|
location,
|
||||||
|
chapterTitle,
|
||||||
|
progress,
|
||||||
|
isLoading: epubLoading,
|
||||||
|
error: epubError,
|
||||||
|
handleLocationChanged,
|
||||||
|
handleGetRendition,
|
||||||
|
navigateToHref,
|
||||||
|
beginBookmarkPeek,
|
||||||
|
resumeReadingAnchor,
|
||||||
|
isBookmarkPeekActive,
|
||||||
|
readingAnchor,
|
||||||
|
goToNextPage,
|
||||||
|
goToPrevPage,
|
||||||
|
applySettings,
|
||||||
|
epubOptions,
|
||||||
|
renditionRef,
|
||||||
|
} = useEpubReader(bookId, settings.margin_width, initialEpubLocation);
|
||||||
|
|
||||||
|
const { pendingSelection, clearSelection } = useEpubSelection(
|
||||||
|
renditionRef,
|
||||||
|
chapterTitle,
|
||||||
|
renditionVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { markers } = useAnnotations();
|
||||||
|
const bookMarkers = useMemo(
|
||||||
|
() => markers.filter((m) => m.ebook_id === bookId),
|
||||||
|
[markers, bookId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEpubHighlights(bookId, renditionRef, renditionVersion);
|
||||||
|
const railItems = useBookmarkRailLayout(bookMarkers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applySettings(settings);
|
||||||
|
}, [settings, applySettings]);
|
||||||
|
|
||||||
|
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 handleTocChanged = useCallback((toc: EpubTocItem[]) => {
|
||||||
|
setTocItems(toc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTocNavigate = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
navigateToHref(href);
|
||||||
|
},
|
||||||
|
[navigateToHref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGetRendition = useCallback(
|
||||||
|
(rendition: Parameters<typeof handleGetRendition>[0]) => {
|
||||||
|
handleGetRendition(rendition);
|
||||||
|
setRenditionVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
[handleGetRendition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoToMarker = useCallback(
|
||||||
|
(marker: MarkerEntry) => {
|
||||||
|
beginBookmarkPeek(marker.epub_cfi);
|
||||||
|
setMarkersOpen(false);
|
||||||
|
},
|
||||||
|
[beginBookmarkPeek],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (epubLoading) {
|
||||||
|
return (
|
||||||
|
<div className="reader-loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p>{t("reader.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epubError || !epubUrl) {
|
||||||
|
return (
|
||||||
|
<div className="reader-loading">
|
||||||
|
<p className="reader-error">{epubError ?? t("reader.unableToOpen")}</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={progress}
|
||||||
|
onBack={() => navigate("/")}
|
||||||
|
onToggleToc={() => setTocOpen((v) => !v)}
|
||||||
|
onToggleSettings={() => setSettingsOpen((v) => !v)}
|
||||||
|
onToggleMarkers={() => setMarkersOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableOfContents
|
||||||
|
items={tocItems}
|
||||||
|
isOpen={tocOpen}
|
||||||
|
onClose={() => setTocOpen(false)}
|
||||||
|
onNavigate={handleTocNavigate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BookMarkersPanel
|
||||||
|
ebookId={bookId}
|
||||||
|
isOpen={markersOpen}
|
||||||
|
onClose={() => setMarkersOpen(false)}
|
||||||
|
onGoToPassage={handleGoToMarker}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReadingSettingsPanel
|
||||||
|
format="epub"
|
||||||
|
settings={settings}
|
||||||
|
isOpen={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onUpdate={updateSettings}
|
||||||
|
onFlush={flushSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pendingSelection && (
|
||||||
|
<SelectionPopover
|
||||||
|
ebookId={bookId}
|
||||||
|
selection={pendingSelection}
|
||||||
|
onClose={clearSelection}
|
||||||
|
onSaved={clearSelection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="reader-epub-container">
|
||||||
|
<EpubView
|
||||||
|
url={epubUrl}
|
||||||
|
location={location}
|
||||||
|
locationChanged={handleLocationChanged}
|
||||||
|
tocChanged={handleTocChanged}
|
||||||
|
getRendition={(rendition) =>
|
||||||
|
onGetRendition(rendition as unknown as Parameters<typeof handleGetRendition>[0])
|
||||||
|
}
|
||||||
|
epubInitOptions={{ openAs: "epub" }}
|
||||||
|
epubOptions={epubOptions}
|
||||||
|
epubViewStyles={EpubViewStyle}
|
||||||
|
loadingView={
|
||||||
|
<div className="reader-loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BookmarkReaderRail
|
||||||
|
items={railItems}
|
||||||
|
onGoToBookmark={beginBookmarkPeek}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reader-page-nav reader-page-nav--prev"
|
||||||
|
onClick={goToPrevPage}
|
||||||
|
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}
|
||||||
|
aria-label={t("reader.nextPage")}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18n-lite";
|
||||||
|
|
||||||
|
const DISMISS_KEY = "cloud-reader.pdf-limitations-dismissed";
|
||||||
|
|
||||||
|
interface PdfLimitationsNoticeProps {
|
||||||
|
bookId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfLimitationsNotice({ bookId }: PdfLimitationsNoticeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const storageKey = `${DISMISS_KEY}:${bookId}`;
|
||||||
|
const [visible, setVisible] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(storageKey) !== "1";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setVisible(false);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, "1");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reader-pdf-notice" role="status">
|
||||||
|
<div className="reader-pdf-notice-text">
|
||||||
|
<strong className="reader-pdf-notice-title">{t("reader.pdfLimitationsTitle")}</strong>
|
||||||
|
<p className="reader-pdf-notice-body">{t("reader.pdfLimitationsBody")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reader-pdf-notice-dismiss"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label={t("reader.pdfLimitationsDismissAria")}
|
||||||
|
>
|
||||||
|
{t("common.dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
import { pdfjs, type PdfDocumentProxy } from "@/utils/pdfjsSetup";
|
||||||
|
import type { PdfRect } from "@/utils/pdfAnchor";
|
||||||
|
import { highlightBackgroundStyle } from "@/constants/bookmarkHighlightColors";
|
||||||
|
|
||||||
|
interface PdfHighlightOverlay {
|
||||||
|
rects: PdfRect[];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfViewerProps {
|
||||||
|
document: PdfDocumentProxy;
|
||||||
|
pageNumber: number;
|
||||||
|
scale: number;
|
||||||
|
highlights?: PdfHighlightOverlay[];
|
||||||
|
pageWrapRef?: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfViewer({
|
||||||
|
document,
|
||||||
|
pageNumber,
|
||||||
|
scale,
|
||||||
|
highlights = [],
|
||||||
|
pageWrapRef,
|
||||||
|
}: PdfViewerProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const internalWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wrapRef = pageWrapRef ?? internalWrapRef;
|
||||||
|
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
if (renderTaskRef.current) {
|
||||||
|
renderTaskRef.current.cancel();
|
||||||
|
renderTaskRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const textLayer = textLayerRef.current;
|
||||||
|
if (!canvas || !textLayer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await document.getPage(pageNumber);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
textLayer.style.width = `${viewport.width}px`;
|
||||||
|
textLayer.style.height = `${viewport.height}px`;
|
||||||
|
textLayer.innerHTML = "";
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const renderTask = page.render({ canvasContext: ctx, viewport });
|
||||||
|
renderTaskRef.current = renderTask;
|
||||||
|
await renderTask.promise;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
const textTask = (
|
||||||
|
pdfjs as typeof pdfjs & {
|
||||||
|
renderTextLayer: (params: {
|
||||||
|
textContentSource: typeof textContent;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
viewport: typeof viewport;
|
||||||
|
}) => { promise: Promise<void> };
|
||||||
|
}
|
||||||
|
).renderTextLayer({
|
||||||
|
textContentSource: textContent,
|
||||||
|
container: textLayer,
|
||||||
|
viewport,
|
||||||
|
});
|
||||||
|
await textTask.promise;
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
textLayerRef.current && (textLayerRef.current.innerHTML = "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderPage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (renderTaskRef.current) {
|
||||||
|
renderTaskRef.current.cancel();
|
||||||
|
renderTaskRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [document, pageNumber, scale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="pdf-page-wrap">
|
||||||
|
<canvas ref={canvasRef} className="pdf-page-canvas" />
|
||||||
|
<div ref={textLayerRef} className="pdf-text-layer" />
|
||||||
|
<div className="pdf-highlight-layer" aria-hidden>
|
||||||
|
{highlights.flatMap((hl, hi) =>
|
||||||
|
hl.rects.map((rect, ri) => {
|
||||||
|
const [x, y, w, h] = rect;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${hi}-${ri}`}
|
||||||
|
className="pdf-highlight-rect"
|
||||||
|
style={{
|
||||||
|
left: `${x * 100}%`,
|
||||||
|
top: `${y * 100}%`,
|
||||||
|
width: `${w * 100}%`,
|
||||||
|
height: `${h * 100}%`,
|
||||||
|
...highlightBackgroundStyle(hl.color),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ import type {
|
|||||||
import type { SettingsPersistMode } from "../../hooks/useReadingSettings";
|
import type { SettingsPersistMode } from "../../hooks/useReadingSettings";
|
||||||
|
|
||||||
interface ReadingSettingsPanelProps {
|
interface ReadingSettingsPanelProps {
|
||||||
|
format?: "epub" | "pdf";
|
||||||
settings: ReadingSettings;
|
settings: ReadingSettings;
|
||||||
|
pdfScale?: number;
|
||||||
|
onPdfScaleChange?: (scale: number) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (
|
onUpdate: (
|
||||||
@@ -44,7 +47,10 @@ const ORIENTATION_OPTIONS: { value: OrientationLock; labelKey: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function ReadingSettingsPanel({
|
export default function ReadingSettingsPanel({
|
||||||
|
format = "epub",
|
||||||
settings,
|
settings,
|
||||||
|
pdfScale = 1.25,
|
||||||
|
onPdfScaleChange,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -115,6 +121,26 @@ export default function ReadingSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{format === "pdf" && onPdfScaleChange && (
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3 className="settings-section-title">
|
||||||
|
{t("reader.pdfZoom", { value: Math.round(pdfScale * 100) })}
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.75"
|
||||||
|
max="2.5"
|
||||||
|
step="0.05"
|
||||||
|
value={pdfScale}
|
||||||
|
onChange={(e) => onPdfScaleChange(Number(e.target.value))}
|
||||||
|
className="settings-slider"
|
||||||
|
aria-label={t("reader.pdfZoomAria")}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{format === "epub" && (
|
||||||
|
<>
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h3 className="settings-section-title">{t("reader.font")}</h3>
|
<h3 className="settings-section-title">{t("reader.font")}</h3>
|
||||||
<div className="font-grid">
|
<div className="font-grid">
|
||||||
@@ -184,6 +210,8 @@ export default function ReadingSettingsPanel({
|
|||||||
aria-label={t("reader.marginWidthAria")}
|
aria-label={t("reader.marginWidthAria")}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h3 className="settings-section-title">
|
<h3 className="settings-section-title">
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ interface SearchSuggestionsProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectSuggestion: () => void;
|
onSelectSuggestion: () => void;
|
||||||
onPdfBook?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion, onPdfBook }: SearchSuggestionsProps) {
|
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||||
@@ -92,10 +91,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion,
|
|||||||
|
|
||||||
const handleSelect = (book: SuggestionItem) => {
|
const handleSelect = (book: SuggestionItem) => {
|
||||||
onSelectSuggestion();
|
onSelectSuggestion();
|
||||||
if (book.format === "pdf") {
|
|
||||||
onPdfBook?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/read/${book.id}`);
|
navigate(`/read/${book.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function useEpubReader(
|
|||||||
try {
|
try {
|
||||||
const [progressData, blob] = await Promise.all([
|
const [progressData, blob] = await Promise.all([
|
||||||
getReadingProgress(bookId).catch(() => null),
|
getReadingProgress(bookId).catch(() => null),
|
||||||
booksApi.getEpubFile(bookId),
|
booksApi.getEbookFile(bookId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||||
|
import { isPdfAnchor, parsePdfAnchor } from "@/utils/pdfAnchor";
|
||||||
|
import { normalizeHighlightColor } from "@/constants/bookmarkHighlightColors";
|
||||||
|
|
||||||
|
export interface PdfPageHighlight {
|
||||||
|
rects: [number, number, number, number][];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePdfHighlights(ebookId: number, currentPage: number): PdfPageHighlight[] {
|
||||||
|
const { markers, loadBookmarks } = useAnnotations();
|
||||||
|
|
||||||
|
const bookMarkers = useMemo(
|
||||||
|
() => markers.filter((m) => m.ebook_id === ebookId),
|
||||||
|
[markers, ebookId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ebookId || Number.isNaN(ebookId)) return;
|
||||||
|
void loadBookmarks(ebookId);
|
||||||
|
}, [ebookId, loadBookmarks]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const highlights: PdfPageHighlight[] = [];
|
||||||
|
for (const marker of bookMarkers) {
|
||||||
|
if (!isPdfAnchor(marker.epub_cfi)) continue;
|
||||||
|
const parsed = parsePdfAnchor(marker.epub_cfi);
|
||||||
|
if (!parsed || parsed.page !== currentPage || !parsed.rects.length) continue;
|
||||||
|
highlights.push({
|
||||||
|
rects: parsed.rects,
|
||||||
|
color: normalizeHighlightColor(marker.highlight_color),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return highlights;
|
||||||
|
}, [bookMarkers, currentPage]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* usePdfReader — load PDF blob, page navigation, and persist reading progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { booksApi } from "../api/books";
|
||||||
|
import { getReadingProgress, updateReadingProgress } from "../api/reader";
|
||||||
|
import type { ReadingProgress } from "../types/reader";
|
||||||
|
import { parsePdfAnchor } from "../utils/pdfAnchor";
|
||||||
|
import { pdfjs, type PdfDocumentProxy } from "../utils/pdfjsSetup";
|
||||||
|
|
||||||
|
export interface PdfReadingAnchor {
|
||||||
|
page: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePdfReaderReturn {
|
||||||
|
pdfDocument: PdfDocumentProxy | null;
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
scale: number;
|
||||||
|
setScale: (scale: number) => void;
|
||||||
|
chapterTitle: string;
|
||||||
|
progress: ReadingProgress | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isBookmarkPeekActive: boolean;
|
||||||
|
readingAnchor: PdfReadingAnchor | null;
|
||||||
|
pageWrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
goToNextPage: () => void;
|
||||||
|
goToPrevPage: () => void;
|
||||||
|
beginBookmarkPeek: (targetAnchor: string) => void;
|
||||||
|
resumeReadingAnchor: () => void;
|
||||||
|
goToAnchor: (anchor: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageToPercentage(page: number, pageCount: number): number {
|
||||||
|
if (pageCount <= 0) return 0;
|
||||||
|
return Math.round((page / pageCount) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePdfReader(
|
||||||
|
bookId: number,
|
||||||
|
pageCountHint = 0,
|
||||||
|
initialAnchor?: string,
|
||||||
|
): UsePdfReaderReturn {
|
||||||
|
const [pdfDocument, setPdfDocument] = useState<PdfDocumentProxy | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageCount, setPageCount] = useState(pageCountHint);
|
||||||
|
const [scale, setScale] = useState(1.25);
|
||||||
|
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
|
||||||
|
const [readingAnchor, setReadingAnchor] = useState<PdfReadingAnchor | null>(null);
|
||||||
|
|
||||||
|
const pageWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const isBookmarkPeekRef = useRef(false);
|
||||||
|
const readingAnchorRef = useRef<PdfReadingAnchor | null>(null);
|
||||||
|
const pageCountRef = useRef(pageCountHint);
|
||||||
|
|
||||||
|
const clearPeekState = useCallback(() => {
|
||||||
|
isBookmarkPeekRef.current = false;
|
||||||
|
readingAnchorRef.current = null;
|
||||||
|
setIsBookmarkPeekActive(false);
|
||||||
|
setReadingAnchor(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPeekState = useCallback((anchor: PdfReadingAnchor) => {
|
||||||
|
readingAnchorRef.current = anchor;
|
||||||
|
isBookmarkPeekRef.current = true;
|
||||||
|
setReadingAnchor(anchor);
|
||||||
|
setIsBookmarkPeekActive(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushProgress = useCallback(
|
||||||
|
async (page: number, total: number) => {
|
||||||
|
if (isBookmarkPeekRef.current) return;
|
||||||
|
const percentage = pageToPercentage(page, total);
|
||||||
|
try {
|
||||||
|
const updated = await updateReadingProgress(bookId, {
|
||||||
|
current_chapter: page,
|
||||||
|
percentage,
|
||||||
|
current_position: percentage,
|
||||||
|
});
|
||||||
|
setProgress(updated);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bookId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduleProgress = useCallback(
|
||||||
|
(page: number, total: number) => {
|
||||||
|
if (isBookmarkPeekRef.current) return;
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
void flushProgress(page, total);
|
||||||
|
}, 800);
|
||||||
|
},
|
||||||
|
[flushProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPage = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
const total = pageCountRef.current || pageCount;
|
||||||
|
const clamped = Math.max(1, Math.min(page, total || page));
|
||||||
|
setCurrentPage(clamped);
|
||||||
|
if (total > 0) scheduleProgress(clamped, total);
|
||||||
|
},
|
||||||
|
[pageCount, scheduleProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToAnchor = useCallback(
|
||||||
|
(anchor: string) => {
|
||||||
|
const parsed = parsePdfAnchor(anchor);
|
||||||
|
if (!parsed) return;
|
||||||
|
goToPage(parsed.page);
|
||||||
|
},
|
||||||
|
[goToPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const captureAnchorFromCurrent = useCallback((): PdfReadingAnchor | null => {
|
||||||
|
const total = pageCountRef.current || pageCount;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
return {
|
||||||
|
page: currentPage,
|
||||||
|
percentage: pageToPercentage(currentPage, total),
|
||||||
|
};
|
||||||
|
}, [currentPage, pageCount]);
|
||||||
|
|
||||||
|
const beginBookmarkPeek = useCallback(
|
||||||
|
(targetAnchor: string) => {
|
||||||
|
const parsed = parsePdfAnchor(targetAnchor);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
if (!isBookmarkPeekRef.current) {
|
||||||
|
let anchor = captureAnchorFromCurrent();
|
||||||
|
if (!anchor && progress) {
|
||||||
|
const page = progress.current_chapter || 1;
|
||||||
|
const pct = progress.percentage ?? progress.current_position ?? 0;
|
||||||
|
anchor = { page, percentage: pct > 0 ? Math.round(pct) : pageToPercentage(page, pageCountRef.current) };
|
||||||
|
}
|
||||||
|
if (anchor) setPeekState(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(parsed.page);
|
||||||
|
},
|
||||||
|
[captureAnchorFromCurrent, goToPage, progress, setPeekState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeReadingAnchor = useCallback(() => {
|
||||||
|
const anchor = readingAnchorRef.current;
|
||||||
|
clearPeekState();
|
||||||
|
if (!anchor) return;
|
||||||
|
goToPage(anchor.page);
|
||||||
|
const total = pageCountRef.current || pageCount;
|
||||||
|
void flushProgress(anchor.page, total);
|
||||||
|
}, [clearPeekState, flushProgress, goToPage, pageCount]);
|
||||||
|
|
||||||
|
const goToNextPage = useCallback(() => {
|
||||||
|
goToPage(currentPage + 1);
|
||||||
|
}, [currentPage, goToPage]);
|
||||||
|
|
||||||
|
const goToPrevPage = useCallback(() => {
|
||||||
|
goToPage(currentPage - 1);
|
||||||
|
}, [currentPage, goToPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
clearPeekState();
|
||||||
|
|
||||||
|
async function loadPdf() {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [progressData, blob] = await Promise.all([
|
||||||
|
getReadingProgress(bookId).catch(() => null),
|
||||||
|
booksApi.getEbookFile(bookId),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const loadingTask = pdfjs.getDocument({ data: await blob.arrayBuffer() });
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const numPages = doc.numPages;
|
||||||
|
pageCountRef.current = numPages;
|
||||||
|
setPdfDocument(doc);
|
||||||
|
setPageCount(numPages);
|
||||||
|
|
||||||
|
const peekParsed =
|
||||||
|
initialAnchor && parsePdfAnchor(initialAnchor) ? parsePdfAnchor(initialAnchor) : null;
|
||||||
|
const savedPage = progressData?.current_chapter || 0;
|
||||||
|
const startPage = peekParsed?.page ?? (savedPage > 0 ? savedPage : 1);
|
||||||
|
|
||||||
|
if (progressData) setProgress(progressData);
|
||||||
|
|
||||||
|
if (peekParsed && savedPage > 0) {
|
||||||
|
setPeekState({
|
||||||
|
page: savedPage,
|
||||||
|
percentage: pageToPercentage(savedPage, numPages),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPage(Math.min(startPage, numPages));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to load PDF";
|
||||||
|
setError(message.includes("password") ? "PDF_PASSWORD" : message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadPdf();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
clearPeekState();
|
||||||
|
};
|
||||||
|
}, [bookId, initialAnchor, clearPeekState, setPeekState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pageCountRef.current = pageCount;
|
||||||
|
}, [pageCount]);
|
||||||
|
|
||||||
|
const chapterTitle =
|
||||||
|
pageCount > 0
|
||||||
|
? `Page ${currentPage} / ${pageCount}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdfDocument,
|
||||||
|
currentPage,
|
||||||
|
pageCount,
|
||||||
|
scale,
|
||||||
|
setScale,
|
||||||
|
chapterTitle,
|
||||||
|
progress,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isBookmarkPeekActive,
|
||||||
|
readingAnchor,
|
||||||
|
pageWrapRef,
|
||||||
|
goToPage,
|
||||||
|
goToNextPage,
|
||||||
|
goToPrevPage,
|
||||||
|
beginBookmarkPeek,
|
||||||
|
resumeReadingAnchor,
|
||||||
|
goToAnchor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { PendingSelection } from "./useEpubSelection";
|
||||||
|
import { rectsFromSelection, serializePdfAnchor } from "../utils/pdfAnchor";
|
||||||
|
|
||||||
|
export function usePdfSelection(
|
||||||
|
pageWrapRef: React.RefObject<HTMLDivElement | null>,
|
||||||
|
currentPage: number,
|
||||||
|
chapterTitle: string,
|
||||||
|
) {
|
||||||
|
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
||||||
|
const chapterTitleRef = useRef(chapterTitle);
|
||||||
|
chapterTitleRef.current = chapterTitle;
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setPendingSelection(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrap = pageWrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const text = sel?.toString()?.trim() ?? "";
|
||||||
|
if (!text || !sel || sel.rangeCount === 0) {
|
||||||
|
setPendingSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!wrap.contains(range.commonAncestorContainer)) {
|
||||||
|
setPendingSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rects = rectsFromSelection(range, wrap);
|
||||||
|
const anchor = serializePdfAnchor(currentPage, rects.length > 0 ? rects : undefined);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
setPendingSelection({
|
||||||
|
epubCfi: anchor,
|
||||||
|
locationText: text.slice(0, 2000),
|
||||||
|
chapterIndex: currentPage - 1,
|
||||||
|
chapterTitle: chapterTitleRef.current,
|
||||||
|
anchorTop: rect.bottom + 8,
|
||||||
|
anchorLeft: Math.min(rect.left, window.innerWidth - 280),
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
wrap.addEventListener("mouseup", handler);
|
||||||
|
return () => wrap.removeEventListener("mouseup", handler);
|
||||||
|
}, [pageWrapRef, currentPage]);
|
||||||
|
|
||||||
|
return { pendingSelection, clearSelection };
|
||||||
|
}
|
||||||
@@ -118,6 +118,15 @@ const enUS = {
|
|||||||
loadBookFailed: "Failed to load book details",
|
loadBookFailed: "Failed to load book details",
|
||||||
epubOnly: "PDF reading is not supported in the web reader. EPUB only.",
|
epubOnly: "PDF reading is not supported in the web reader. EPUB only.",
|
||||||
unableToOpen: "Unable to open this book.",
|
unableToOpen: "Unable to open this book.",
|
||||||
|
unsupportedFormat: "This file format cannot be opened in the reader.",
|
||||||
|
pdfPassword: "This PDF is password-protected and cannot be opened.",
|
||||||
|
pdfPageLabel: "Page {{page}}",
|
||||||
|
pdfZoom: "Zoom: {{value}}%",
|
||||||
|
pdfZoomAria: "PDF zoom level",
|
||||||
|
pdfLimitationsTitle: "PDF reading",
|
||||||
|
pdfLimitationsBody:
|
||||||
|
"This file is a PDF. Some features are limited compared to EPUB: bookmarks and notes are not available, and there are no chapters in the table of contents—only pages.",
|
||||||
|
pdfLimitationsDismissAria: "Dismiss PDF limitations notice",
|
||||||
backToLibrary: "Back to library",
|
backToLibrary: "Back to library",
|
||||||
prevPage: "Previous page",
|
prevPage: "Previous page",
|
||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ const esES: Locale = {
|
|||||||
loadBookFailed: "Error al cargar los detalles del libro",
|
loadBookFailed: "Error al cargar los detalles del libro",
|
||||||
epubOnly: "La lectura de PDF no está disponible en el lector web. Solo EPUB.",
|
epubOnly: "La lectura de PDF no está disponible en el lector web. Solo EPUB.",
|
||||||
unableToOpen: "No se puede abrir este libro.",
|
unableToOpen: "No se puede abrir este libro.",
|
||||||
|
unsupportedFormat: "Este formato de archivo no se puede abrir en el lector.",
|
||||||
|
pdfPassword: "Este PDF está protegido con contraseña y no se puede abrir.",
|
||||||
|
pdfPageLabel: "Página {{page}}",
|
||||||
|
pdfZoom: "Zoom: {{value}}%",
|
||||||
|
pdfZoomAria: "Nivel de zoom del PDF",
|
||||||
|
pdfLimitationsTitle: "Lectura en PDF",
|
||||||
|
pdfLimitationsBody:
|
||||||
|
"Este archivo es un PDF. Algunas funciones están limitadas respecto al EPUB: no hay marcadores ni notas, y la tabla de contenidos no muestra capítulos, solo páginas.",
|
||||||
|
pdfLimitationsDismissAria: "Cerrar aviso de limitaciones del PDF",
|
||||||
backToLibrary: "Volver a la biblioteca",
|
backToLibrary: "Volver a la biblioteca",
|
||||||
prevPage: "Página anterior",
|
prevPage: "Página anterior",
|
||||||
nextPage: "Página siguiente",
|
nextPage: "Página siguiente",
|
||||||
|
|||||||
@@ -191,13 +191,9 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
const openBook = useCallback(
|
const openBook = useCallback(
|
||||||
(book: LibraryBook) => {
|
(book: LibraryBook) => {
|
||||||
if (book.format === "pdf") {
|
|
||||||
setReaderNotice(t("library.pdfNotice"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/read/${book.id}`);
|
navigate(`/read/${book.id}`);
|
||||||
},
|
},
|
||||||
[navigate, t],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
@@ -337,7 +333,6 @@ export function LibraryPage() {
|
|||||||
visible={showSuggestions && !voiceSearch.isListening}
|
visible={showSuggestions && !voiceSearch.isListening}
|
||||||
onClose={() => setShowSuggestions(false)}
|
onClose={() => setShowSuggestions(false)}
|
||||||
onSelectSuggestion={() => setShowSuggestions(false)}
|
onSelectSuggestion={() => setShowSuggestions(false)}
|
||||||
onPdfBook={() => setReaderNotice(t("library.pdfNotice"))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* ReadingPage — full-screen EPUB reading view powered by react-reader (epub.js).
|
* ReadingPage — routes to EPUB or PDF reading views by ebook format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18n-lite";
|
import { useTranslation } from "react-i18n-lite";
|
||||||
import { EpubView, EpubViewStyle } from "react-reader";
|
|
||||||
import { booksApi } from "../api/books";
|
import { booksApi } from "../api/books";
|
||||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
import { EpubReadingView } from "../components/reader/EpubReadingView";
|
||||||
import { useBookmarkRailLayout } from "../hooks/useBookmarkRailLayout";
|
import { PdfReadingView } from "../components/reader/PdfReadingView";
|
||||||
import { useEpubHighlights } from "../hooks/useEpubHighlights";
|
|
||||||
import { useEpubReader } from "../hooks/useEpubReader";
|
|
||||||
import { useEpubSelection } from "../hooks/useEpubSelection";
|
|
||||||
import { useReadingSettings } from "../hooks/useReadingSettings";
|
|
||||||
import type { EBookDetail } from "../types/book";
|
import type { EBookDetail } from "../types/book";
|
||||||
import type { EpubTocItem } from "../components/reader/TableOfContents";
|
|
||||||
import { SelectionPopover } from "../components/reader/SelectionPopover";
|
|
||||||
import { BookMarkersPanel } from "../components/reader/BookMarkersPanel";
|
|
||||||
import { BookmarkReaderRail } from "../components/reader/BookmarkReaderRail";
|
|
||||||
import { ResumeReadingButton } from "../components/reader/ResumeReadingButton";
|
|
||||||
import type { MarkerEntry } from "@/types";
|
|
||||||
import "../reader.css";
|
import "../reader.css";
|
||||||
|
|
||||||
const ReaderToolbar = lazy(() => import("../components/reader/ReaderToolbar"));
|
|
||||||
const TableOfContents = lazy(() => import("../components/reader/TableOfContents"));
|
|
||||||
const ReadingSettingsPanel = lazy(
|
|
||||||
() => import("../components/reader/ReadingSettingsPanel"),
|
|
||||||
);
|
|
||||||
|
|
||||||
type LocationState = { epubLocation?: string } | null;
|
type LocationState = { epubLocation?: string } | null;
|
||||||
|
|
||||||
export default function ReadingPage() {
|
export default function ReadingPage() {
|
||||||
@@ -36,54 +19,11 @@ export default function ReadingPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const routerLocation = useLocation();
|
const routerLocation = useLocation();
|
||||||
const bookId = Number(id);
|
const bookId = Number(id);
|
||||||
const initialEpubLocation = (routerLocation.state as LocationState)?.epubLocation;
|
const initialAnchor = (routerLocation.state as LocationState)?.epubLocation;
|
||||||
|
|
||||||
const [book, setBook] = useState<EBookDetail | null>(null);
|
const [book, setBook] = useState<EBookDetail | null>(null);
|
||||||
const [bookLoading, setBookLoading] = useState(true);
|
const [bookLoading, setBookLoading] = useState(true);
|
||||||
const [bookError, setBookError] = useState<string | null>(null);
|
const [bookError, setBookError] = useState<string | null>(null);
|
||||||
const [tocOpen, setTocOpen] = useState(false);
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const [markersOpen, setMarkersOpen] = useState(false);
|
|
||||||
const [tocItems, setTocItems] = useState<EpubTocItem[]>([]);
|
|
||||||
const [renditionVersion, setRenditionVersion] = useState(0);
|
|
||||||
|
|
||||||
const { settings, updateSettings, flushSettings } = useReadingSettings();
|
|
||||||
|
|
||||||
const {
|
|
||||||
epubUrl,
|
|
||||||
location,
|
|
||||||
chapterTitle,
|
|
||||||
progress,
|
|
||||||
isLoading: epubLoading,
|
|
||||||
error: epubError,
|
|
||||||
handleLocationChanged,
|
|
||||||
handleGetRendition,
|
|
||||||
navigateToHref,
|
|
||||||
beginBookmarkPeek,
|
|
||||||
resumeReadingAnchor,
|
|
||||||
isBookmarkPeekActive,
|
|
||||||
readingAnchor,
|
|
||||||
goToNextPage,
|
|
||||||
goToPrevPage,
|
|
||||||
applySettings,
|
|
||||||
epubOptions,
|
|
||||||
renditionRef,
|
|
||||||
} = useEpubReader(bookId, settings.margin_width, initialEpubLocation);
|
|
||||||
|
|
||||||
const { pendingSelection, clearSelection } = useEpubSelection(
|
|
||||||
renditionRef,
|
|
||||||
chapterTitle,
|
|
||||||
renditionVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { markers } = useAnnotations();
|
|
||||||
const bookMarkers = useMemo(
|
|
||||||
() => markers.filter((m) => m.ebook_id === bookId),
|
|
||||||
[markers, bookId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEpubHighlights(bookId, renditionRef, renditionVersion);
|
|
||||||
const railItems = useBookmarkRailLayout(bookMarkers);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bookId || Number.isNaN(bookId)) return;
|
if (!bookId || Number.isNaN(bookId)) return;
|
||||||
@@ -91,65 +31,12 @@ export default function ReadingPage() {
|
|||||||
setBookError(null);
|
setBookError(null);
|
||||||
booksApi
|
booksApi
|
||||||
.getEBook(bookId)
|
.getEBook(bookId)
|
||||||
.then((data) => {
|
.then((data) => setBook(data))
|
||||||
if (data.format !== "epub") {
|
|
||||||
setBookError(t("reader.epubOnly"));
|
|
||||||
setBook(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBook(data);
|
|
||||||
})
|
|
||||||
.catch(() => setBookError(t("reader.loadBookFailed")))
|
.catch(() => setBookError(t("reader.loadBookFailed")))
|
||||||
.finally(() => setBookLoading(false));
|
.finally(() => setBookLoading(false));
|
||||||
}, [bookId, t]);
|
}, [bookId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (bookLoading) {
|
||||||
applySettings(settings);
|
|
||||||
}, [settings, applySettings]);
|
|
||||||
|
|
||||||
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 handleTocChanged = useCallback((toc: EpubTocItem[]) => {
|
|
||||||
setTocItems(toc);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTocNavigate = useCallback(
|
|
||||||
(href: string) => {
|
|
||||||
navigateToHref(href);
|
|
||||||
},
|
|
||||||
[navigateToHref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onGetRendition = useCallback(
|
|
||||||
(rendition: Parameters<typeof handleGetRendition>[0]) => {
|
|
||||||
handleGetRendition(rendition);
|
|
||||||
setRenditionVersion((v) => v + 1);
|
|
||||||
},
|
|
||||||
[handleGetRendition],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGoToMarker = useCallback(
|
|
||||||
(marker: MarkerEntry) => {
|
|
||||||
beginBookmarkPeek(marker.epub_cfi);
|
|
||||||
setMarkersOpen(false);
|
|
||||||
},
|
|
||||||
[beginBookmarkPeek],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = bookLoading || epubLoading;
|
|
||||||
const error = bookError ?? epubError;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="reader-loading">
|
<div className="reader-loading">
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
@@ -158,10 +45,25 @@ export default function ReadingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !epubUrl) {
|
if (bookError || !book) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-loading">
|
<div className="reader-loading">
|
||||||
<p className="reader-error">{error ?? t("reader.unableToOpen")}</p>
|
<p className="reader-error">{bookError ?? t("reader.unableToOpen")}</p>
|
||||||
|
<button type="button" className="back-button" onClick={() => navigate("/")}>
|
||||||
|
{t("reader.backToLibrary")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (book.format === "pdf") {
|
||||||
|
return <PdfReadingView book={book} bookId={bookId} initialAnchor={initialAnchor} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (book.format !== "epub") {
|
||||||
|
return (
|
||||||
|
<div className="reader-loading">
|
||||||
|
<p className="reader-error">{t("reader.unsupportedFormat")}</p>
|
||||||
<button type="button" className="back-button" onClick={() => navigate("/")}>
|
<button type="button" className="back-button" onClick={() => navigate("/")}>
|
||||||
{t("reader.backToLibrary")}
|
{t("reader.backToLibrary")}
|
||||||
</button>
|
</button>
|
||||||
@@ -170,99 +72,6 @@ export default function ReadingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<EpubReadingView book={book} bookId={bookId} initialEpubLocation={initialAnchor} />
|
||||||
fallback={
|
|
||||||
<div className="reader-loading">
|
|
||||||
<div className="spinner" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="reader-container" data-theme={settings.theme}>
|
|
||||||
<ReaderToolbar
|
|
||||||
bookTitle={book?.title ?? t("reader.reading")}
|
|
||||||
chapterTitle={chapterTitle || t("reader.reading")}
|
|
||||||
progress={progress}
|
|
||||||
onBack={() => navigate("/")}
|
|
||||||
onToggleToc={() => setTocOpen((v) => !v)}
|
|
||||||
onToggleSettings={() => setSettingsOpen((v) => !v)}
|
|
||||||
onToggleMarkers={() => setMarkersOpen((v) => !v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TableOfContents
|
|
||||||
items={tocItems}
|
|
||||||
isOpen={tocOpen}
|
|
||||||
onClose={() => setTocOpen(false)}
|
|
||||||
onNavigate={handleTocNavigate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BookMarkersPanel
|
|
||||||
ebookId={bookId}
|
|
||||||
isOpen={markersOpen}
|
|
||||||
onClose={() => setMarkersOpen(false)}
|
|
||||||
onGoToPassage={handleGoToMarker}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReadingSettingsPanel
|
|
||||||
settings={settings}
|
|
||||||
isOpen={settingsOpen}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
onUpdate={updateSettings}
|
|
||||||
onFlush={flushSettings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{pendingSelection && (
|
|
||||||
<SelectionPopover
|
|
||||||
ebookId={bookId}
|
|
||||||
selection={pendingSelection}
|
|
||||||
onClose={clearSelection}
|
|
||||||
onSaved={clearSelection}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="reader-epub-container">
|
|
||||||
<EpubView
|
|
||||||
url={epubUrl}
|
|
||||||
location={location}
|
|
||||||
locationChanged={handleLocationChanged}
|
|
||||||
tocChanged={handleTocChanged}
|
|
||||||
getRendition={(rendition) =>
|
|
||||||
onGetRendition(rendition as unknown as Parameters<typeof handleGetRendition>[0])
|
|
||||||
}
|
|
||||||
epubInitOptions={{ openAs: "epub" }}
|
|
||||||
epubOptions={epubOptions}
|
|
||||||
epubViewStyles={EpubViewStyle}
|
|
||||||
loadingView={
|
|
||||||
<div className="reader-loading">
|
|
||||||
<div className="spinner" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BookmarkReaderRail
|
|
||||||
items={railItems}
|
|
||||||
onGoToBookmark={beginBookmarkPeek}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="reader-page-nav reader-page-nav--prev"
|
|
||||||
onClick={goToPrevPage}
|
|
||||||
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}
|
|
||||||
aria-label={t("reader.nextPage")}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -870,4 +870,122 @@
|
|||||||
--reader-toolbar-bg: rgba(255, 255, 255, 0.95);
|
--reader-toolbar-bg: rgba(255, 255, 255, 0.95);
|
||||||
--reader-toolbar-text: #1a1a1a;
|
--reader-toolbar-text: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- PDF limitations notice --- */
|
||||||
|
.reader-pdf-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 52px 12px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #92400e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-notice-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-notice-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-notice-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-notice-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-notice-dismiss:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- PDF reader --- */
|
||||||
|
.reader-pdf-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pdf-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-canvas {
|
||||||
|
display: block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-text-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.25;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-text-layer span {
|
||||||
|
position: absolute;
|
||||||
|
color: transparent;
|
||||||
|
white-space: pre;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-highlight-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-highlight-rect {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 2px;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type PdfRect = [number, number, number, number];
|
||||||
|
|
||||||
|
export interface ParsedPdfAnchor {
|
||||||
|
page: number;
|
||||||
|
rects: PdfRect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF_ANCHOR_PREFIX = "pdf:v1:";
|
||||||
|
|
||||||
|
export function isPdfAnchor(anchor: string): boolean {
|
||||||
|
return anchor.startsWith(PDF_ANCHOR_PREFIX) || anchor.startsWith("pdf:page:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePageHref(href: string): number | null {
|
||||||
|
const match = href.match(/^pdf:page:(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const page = Number(match[1]);
|
||||||
|
return Number.isFinite(page) && page > 0 ? page : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePdfAnchor(page: number, rects?: PdfRect[]): string {
|
||||||
|
const base = `${PDF_ANCHOR_PREFIX}p=${page}`;
|
||||||
|
if (!rects?.length) return base;
|
||||||
|
return `${base};rects=${encodeURIComponent(JSON.stringify(rects))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePdfAnchor(anchor: string): ParsedPdfAnchor | null {
|
||||||
|
if (anchor.startsWith("pdf:page:")) {
|
||||||
|
const page = parsePageHref(anchor);
|
||||||
|
return page ? { page, rects: [] } : null;
|
||||||
|
}
|
||||||
|
if (!anchor.startsWith(PDF_ANCHOR_PREFIX)) return null;
|
||||||
|
|
||||||
|
const pageMatch = anchor.match(/p=(\d+)/);
|
||||||
|
if (!pageMatch) return null;
|
||||||
|
const page = Number(pageMatch[1]);
|
||||||
|
if (!Number.isFinite(page) || page < 1) return null;
|
||||||
|
|
||||||
|
const rectsMatch = anchor.match(/rects=([^;]+)/);
|
||||||
|
let rects: PdfRect[] = [];
|
||||||
|
if (rectsMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(decodeURIComponent(rectsMatch[1])) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
rects = parsed.filter(
|
||||||
|
(r): r is PdfRect =>
|
||||||
|
Array.isArray(r) &&
|
||||||
|
r.length === 4 &&
|
||||||
|
r.every((n) => typeof n === "number" && Number.isFinite(n)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
rects = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { page, rects };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectsFromSelection(
|
||||||
|
range: Range,
|
||||||
|
pageElement: HTMLElement,
|
||||||
|
): PdfRect[] {
|
||||||
|
const pageRect = pageElement.getBoundingClientRect();
|
||||||
|
if (pageRect.width <= 0 || pageRect.height <= 0) return [];
|
||||||
|
|
||||||
|
const rects: PdfRect[] = [];
|
||||||
|
for (const clientRect of range.getClientRects()) {
|
||||||
|
if (clientRect.width <= 0 || clientRect.height <= 0) continue;
|
||||||
|
const x = (clientRect.left - pageRect.left) / pageRect.width;
|
||||||
|
const y = (clientRect.top - pageRect.top) / pageRect.height;
|
||||||
|
const w = clientRect.width / pageRect.width;
|
||||||
|
const h = clientRect.height / pageRect.height;
|
||||||
|
rects.push([
|
||||||
|
Math.max(0, Math.min(1, x)),
|
||||||
|
Math.max(0, Math.min(1, y)),
|
||||||
|
Math.max(0, Math.min(1, w)),
|
||||||
|
Math.max(0, Math.min(1, h)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { EpubTocItem } from "../components/reader/TableOfContents";
|
||||||
|
|
||||||
|
export function fallbackPdfPageToc(
|
||||||
|
pageCount: number,
|
||||||
|
labelForPage: (page: number) => string,
|
||||||
|
): EpubTocItem[] {
|
||||||
|
if (pageCount <= 0) return [];
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => ({
|
||||||
|
label: labelForPage(i + 1),
|
||||||
|
href: `pdf:page:${i + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
|
import.meta.url,
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
export { pdfjs };
|
||||||
|
|
||||||
|
export type PdfDocumentProxy = Awaited<ReturnType<typeof pdfjs.getDocument>>["promise"];
|
||||||
Generated
+25784
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user