Archived
feat: pdf reader
This commit is contained in:
@@ -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}`);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user