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
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"axios": "^1.7.9",
"dompurify": "^3.4.7",
"pdfjs-dist": "^4.10.38",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18n-lite": "^1.0.10",
+7
View File
@@ -23,6 +23,13 @@ export const booksApi = {
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> {
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
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";
interface ReadingSettingsPanelProps {
format?: "epub" | "pdf";
settings: ReadingSettings;
pdfScale?: number;
onPdfScaleChange?: (scale: number) => void;
isOpen: boolean;
onClose: () => void;
onUpdate: (
@@ -44,7 +47,10 @@ const ORIENTATION_OPTIONS: { value: OrientationLock; labelKey: string }[] = [
];
export default function ReadingSettingsPanel({
format = "epub",
settings,
pdfScale = 1.25,
onPdfScaleChange,
isOpen,
onClose,
onUpdate,
@@ -115,6 +121,26 @@ export default function ReadingSettingsPanel({
</div>
</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">
<h3 className="settings-section-title">{t("reader.font")}</h3>
<div className="font-grid">
@@ -184,6 +210,8 @@ export default function ReadingSettingsPanel({
aria-label={t("reader.marginWidthAria")}
/>
</section>
</>
)}
<section className="settings-section">
<h3 className="settings-section-title">
@@ -19,10 +19,9 @@ interface SearchSuggestionsProps {
visible: boolean;
onClose: () => 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 navigate = useNavigate();
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
@@ -92,10 +91,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion,
const handleSelect = (book: SuggestionItem) => {
onSelectSuggestion();
if (book.format === "pdf") {
onPdfBook?.();
return;
}
navigate(`/read/${book.id}`);
};
+1 -1
View File
@@ -199,7 +199,7 @@ export function useEpubReader(
try {
const [progressData, blob] = await Promise.all([
getReadingProgress(bookId).catch(() => null),
booksApi.getEpubFile(bookId),
booksApi.getEbookFile(bookId),
]);
if (cancelled) return;
+37
View File
@@ -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]);
}
+258
View File
@@ -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,
};
}
+57
View File
@@ -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 };
}
+9
View File
@@ -118,6 +118,15 @@ const enUS = {
loadBookFailed: "Failed to load book details",
epubOnly: "PDF reading is not supported in the web reader. EPUB only.",
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",
prevPage: "Previous page",
nextPage: "Next page",
+9
View File
@@ -120,6 +120,15 @@ const esES: Locale = {
loadBookFailed: "Error al cargar los detalles del libro",
epubOnly: "La lectura de PDF no está disponible en el lector web. Solo EPUB.",
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",
prevPage: "Página anterior",
nextPage: "Página siguiente",
+1 -6
View File
@@ -191,13 +191,9 @@ export function LibraryPage() {
const openBook = useCallback(
(book: LibraryBook) => {
if (book.format === "pdf") {
setReaderNotice(t("library.pdfNotice"));
return;
}
navigate(`/read/${book.id}`);
},
[navigate, t],
[navigate],
);
const containerStyle: React.CSSProperties = {
@@ -337,7 +333,6 @@ export function LibraryPage() {
visible={showSuggestions && !voiceSearch.isListening}
onClose={() => setShowSuggestions(false)}
onSelectSuggestion={() => setShowSuggestions(false)}
onPdfBook={() => setReaderNotice(t("library.pdfNotice"))}
/>
</div>
<button
+25 -216
View File
@@ -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 { useTranslation } from "react-i18n-lite";
import { EpubView, EpubViewStyle } from "react-reader";
import { booksApi } from "../api/books";
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 { EpubReadingView } from "../components/reader/EpubReadingView";
import { PdfReadingView } from "../components/reader/PdfReadingView";
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";
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;
export default function ReadingPage() {
@@ -36,54 +19,11 @@ export default function ReadingPage() {
const navigate = useNavigate();
const routerLocation = useLocation();
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 [bookLoading, setBookLoading] = useState(true);
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(() => {
if (!bookId || Number.isNaN(bookId)) return;
@@ -91,65 +31,12 @@ export default function ReadingPage() {
setBookError(null);
booksApi
.getEBook(bookId)
.then((data) => {
if (data.format !== "epub") {
setBookError(t("reader.epubOnly"));
setBook(null);
return;
}
setBook(data);
})
.then((data) => setBook(data))
.catch(() => setBookError(t("reader.loadBookFailed")))
.finally(() => setBookLoading(false));
}, [bookId, t]);
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],
);
const isLoading = bookLoading || epubLoading;
const error = bookError ?? epubError;
if (isLoading) {
if (bookLoading) {
return (
<div className="reader-loading">
<div className="spinner" />
@@ -158,10 +45,25 @@ export default function ReadingPage() {
);
}
if (error || !epubUrl) {
if (bookError || !book) {
return (
<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("/")}>
{t("reader.backToLibrary")}
</button>
@@ -170,99 +72,6 @@ export default function ReadingPage() {
}
return (
<Suspense
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>
<EpubReadingView book={book} bookId={bookId} initialEpubLocation={initialAnchor} />
);
}
+118
View File
@@ -870,4 +870,122 @@
--reader-toolbar-bg: rgba(255, 255, 255, 0.95);
--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;
}
+82
View File
@@ -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;
}
+12
View File
@@ -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}`,
}));
}
+10
View File
@@ -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"];