Archived
fix: pdf bookmark
for pdfs only select pages not text
This commit is contained in:
@@ -12,6 +12,7 @@ interface BookMarkersPanelProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGoToPassage: (marker: MarkerEntry) => void;
|
onGoToPassage: (marker: MarkerEntry) => void;
|
||||||
|
emptyHintKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookMarkersPanel({
|
export function BookMarkersPanel({
|
||||||
@@ -19,6 +20,7 @@ export function BookMarkersPanel({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onGoToPassage,
|
onGoToPassage,
|
||||||
|
emptyHintKey = "annotations.selectTextHint",
|
||||||
}: BookMarkersPanelProps) {
|
}: BookMarkersPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { markers, loadBookmarks, removeBookmark, state } = useAnnotations();
|
const { markers, loadBookmarks, removeBookmark, state } = useAnnotations();
|
||||||
@@ -43,7 +45,7 @@ export function BookMarkersPanel({
|
|||||||
{state.bookmarksLoading ? (
|
{state.bookmarksLoading ? (
|
||||||
<p className="annotations-loading">{t("annotations.loading")}</p>
|
<p className="annotations-loading">{t("annotations.loading")}</p>
|
||||||
) : markers.length === 0 ? (
|
) : markers.length === 0 ? (
|
||||||
<p className="annotations-empty">{t("annotations.selectTextHint")}</p>
|
<p className="annotations-empty">{t(emptyHintKey)}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="marker-thread-list">
|
<ul className="marker-thread-list">
|
||||||
{markers.map((m) => (
|
{markers.map((m) => (
|
||||||
@@ -51,13 +53,15 @@ export function BookMarkersPanel({
|
|||||||
{m.chapter_title && (
|
{m.chapter_title && (
|
||||||
<span className="marker-thread-chapter">{m.chapter_title}</span>
|
<span className="marker-thread-chapter">{m.chapter_title}</span>
|
||||||
)}
|
)}
|
||||||
{m.location_text && (
|
{m.location_text ? (
|
||||||
<CollapsibleMarkerPassage
|
<CollapsibleMarkerPassage
|
||||||
text={m.location_text}
|
text={m.location_text}
|
||||||
highlightColor={m.highlight_color}
|
highlightColor={m.highlight_color}
|
||||||
className="annotation-quote"
|
className="annotation-quote"
|
||||||
/>
|
/>
|
||||||
)}
|
) : m.chapter_title ? (
|
||||||
|
<span className="annotation-quote marker-page-label">{m.chapter_title}</span>
|
||||||
|
) : null}
|
||||||
{m.content ? (
|
{m.content ? (
|
||||||
<CollapsibleMarkerThought text={m.content} />
|
<CollapsibleMarkerThought text={m.content} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ function RailMarker({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const color = normalizeHighlightColor(item.marker.highlight_color);
|
const color = normalizeHighlightColor(item.marker.highlight_color);
|
||||||
const previewText = truncateRailPreview(item.marker.location_text || "");
|
const previewText = truncateRailPreview(
|
||||||
|
item.marker.location_text || item.marker.chapter_title || "",
|
||||||
|
);
|
||||||
const notePreview = item.marker.content
|
const notePreview = item.marker.content
|
||||||
? truncateRailPreview(item.marker.content, 80)
|
? truncateRailPreview(item.marker.content, 80)
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
@@ -7,16 +7,14 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18n-lite";
|
import { useTranslation } from "react-i18n-lite";
|
||||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||||
import { useBookmarkRailLayout } from "../../hooks/useBookmarkRailLayout";
|
import { useBookmarkRailLayout } from "../../hooks/useBookmarkRailLayout";
|
||||||
import { usePdfHighlights } from "../../hooks/usePdfHighlights";
|
|
||||||
import { usePdfReader } from "../../hooks/usePdfReader";
|
import { usePdfReader } from "../../hooks/usePdfReader";
|
||||||
import { usePdfSelection } from "../../hooks/usePdfSelection";
|
|
||||||
import { useReadingSettings } from "../../hooks/useReadingSettings";
|
import { useReadingSettings } from "../../hooks/useReadingSettings";
|
||||||
|
import { useToast } from "../../hooks/useToast";
|
||||||
import { booksApi } from "../../api/books";
|
import { booksApi } from "../../api/books";
|
||||||
import type { EBookDetail } from "../../types/book";
|
import type { EBookDetail } from "../../types/book";
|
||||||
import type { EpubTocItem } from "./TableOfContents";
|
import type { EpubTocItem } from "./TableOfContents";
|
||||||
import { parsePageHref } from "../../utils/pdfAnchor";
|
import { parsePageHref, serializePdfAnchor } from "../../utils/pdfAnchor";
|
||||||
import { fallbackPdfPageToc } from "../../utils/pdfToc";
|
import { fallbackPdfPageToc } from "../../utils/pdfToc";
|
||||||
import { SelectionPopover } from "./SelectionPopover";
|
|
||||||
import { BookMarkersPanel } from "./BookMarkersPanel";
|
import { BookMarkersPanel } from "./BookMarkersPanel";
|
||||||
import { BookmarkReaderRail } from "./BookmarkReaderRail";
|
import { BookmarkReaderRail } from "./BookmarkReaderRail";
|
||||||
import { ResumeReadingButton } from "./ResumeReadingButton";
|
import { ResumeReadingButton } from "./ResumeReadingButton";
|
||||||
@@ -37,6 +35,8 @@ interface PdfReadingViewProps {
|
|||||||
export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewProps) {
|
export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { markers, addMarker, loadBookmarks } = useAnnotations();
|
||||||
|
|
||||||
const [tocOpen, setTocOpen] = useState(false);
|
const [tocOpen, setTocOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
@@ -57,7 +57,6 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
error,
|
error,
|
||||||
isBookmarkPeekActive,
|
isBookmarkPeekActive,
|
||||||
readingAnchor,
|
readingAnchor,
|
||||||
pageWrapRef,
|
|
||||||
goToPage,
|
goToPage,
|
||||||
goToNextPage,
|
goToNextPage,
|
||||||
goToPrevPage,
|
goToPrevPage,
|
||||||
@@ -66,21 +65,18 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
goToAnchor,
|
goToAnchor,
|
||||||
} = usePdfReader(bookId, book.page_count, initialAnchor);
|
} = usePdfReader(bookId, book.page_count, initialAnchor);
|
||||||
|
|
||||||
const { pendingSelection, clearSelection } = usePdfSelection(
|
|
||||||
pageWrapRef,
|
|
||||||
currentPage,
|
|
||||||
chapterTitle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { markers } = useAnnotations();
|
|
||||||
const bookMarkers = useMemo(
|
const bookMarkers = useMemo(
|
||||||
() => markers.filter((m) => m.ebook_id === bookId),
|
() => markers.filter((m) => m.ebook_id === bookId),
|
||||||
[markers, bookId],
|
[markers, bookId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pdfHighlights = usePdfHighlights(bookId, currentPage);
|
|
||||||
const railItems = useBookmarkRailLayout(bookMarkers);
|
const railItems = useBookmarkRailLayout(bookMarkers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId || Number.isNaN(bookId)) return;
|
||||||
|
void loadBookmarks(bookId);
|
||||||
|
}, [bookId, loadBookmarks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void booksApi
|
void booksApi
|
||||||
.getToc(bookId)
|
.getToc(bookId)
|
||||||
@@ -129,6 +125,29 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
[beginBookmarkPeek],
|
[beginBookmarkPeek],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBookmarkPage = useCallback(() => {
|
||||||
|
const anchor = serializePdfAnchor(currentPage);
|
||||||
|
const exists = bookMarkers.some((m) => m.epub_cfi === anchor);
|
||||||
|
if (exists) {
|
||||||
|
showToast({ message: t("reader.pageAlreadyBookmarked"), variant: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void addMarker({
|
||||||
|
ebook: bookId,
|
||||||
|
epub_cfi: anchor,
|
||||||
|
chapter_index: currentPage - 1,
|
||||||
|
chapter_title: t("reader.pdfPageLabel", { page: String(currentPage) }),
|
||||||
|
location_text: "",
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
showToast({ message: t("reader.pageBookmarked"), variant: "success" });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast({ message: t("annotations.saveFailed"), variant: "error" });
|
||||||
|
});
|
||||||
|
}, [addMarker, bookId, bookMarkers, currentPage, showToast, t]);
|
||||||
|
|
||||||
const progressForToolbar = useMemo(() => {
|
const progressForToolbar = useMemo(() => {
|
||||||
if (!progress) return progress;
|
if (!progress) return progress;
|
||||||
const pct = pageCount > 0 ? Math.round((currentPage / pageCount) * 100) : 0;
|
const pct = pageCount > 0 ? Math.round((currentPage / pageCount) * 100) : 0;
|
||||||
@@ -140,6 +159,11 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
};
|
};
|
||||||
}, [progress, currentPage, pageCount]);
|
}, [progress, currentPage, pageCount]);
|
||||||
|
|
||||||
|
const isCurrentPageBookmarked = useMemo(() => {
|
||||||
|
const anchor = serializePdfAnchor(currentPage);
|
||||||
|
return bookMarkers.some((m) => m.epub_cfi === anchor);
|
||||||
|
}, [bookMarkers, currentPage]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-loading">
|
<div className="reader-loading">
|
||||||
@@ -179,6 +203,8 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
onToggleToc={() => setTocOpen((v) => !v)}
|
onToggleToc={() => setTocOpen((v) => !v)}
|
||||||
onToggleSettings={() => setSettingsOpen((v) => !v)}
|
onToggleSettings={() => setSettingsOpen((v) => !v)}
|
||||||
onToggleMarkers={() => setMarkersOpen((v) => !v)}
|
onToggleMarkers={() => setMarkersOpen((v) => !v)}
|
||||||
|
onBookmarkPage={handleBookmarkPage}
|
||||||
|
isCurrentPageBookmarked={isCurrentPageBookmarked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PdfLimitationsNotice bookId={bookId} />
|
<PdfLimitationsNotice bookId={bookId} />
|
||||||
@@ -195,6 +221,7 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
isOpen={markersOpen}
|
isOpen={markersOpen}
|
||||||
onClose={() => setMarkersOpen(false)}
|
onClose={() => setMarkersOpen(false)}
|
||||||
onGoToPassage={handleGoToMarker}
|
onGoToPassage={handleGoToMarker}
|
||||||
|
emptyHintKey="annotations.pdfPageBookmarkHint"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReadingSettingsPanel
|
<ReadingSettingsPanel
|
||||||
@@ -208,23 +235,12 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
|||||||
onFlush={flushSettings}
|
onFlush={flushSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pendingSelection && (
|
|
||||||
<SelectionPopover
|
|
||||||
ebookId={bookId}
|
|
||||||
selection={pendingSelection}
|
|
||||||
onClose={clearSelection}
|
|
||||||
onSaved={clearSelection}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="reader-pdf-container">
|
<main className="reader-pdf-container">
|
||||||
<div className="reader-pdf-scroll">
|
<div className="reader-pdf-scroll">
|
||||||
<PdfViewer
|
<PdfViewer
|
||||||
document={pdfDocument}
|
document={pdfDocument}
|
||||||
pageNumber={currentPage}
|
pageNumber={currentPage}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
highlights={pdfHighlights}
|
|
||||||
pageWrapRef={pageWrapRef}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BookmarkReaderRail
|
<BookmarkReaderRail
|
||||||
|
|||||||
@@ -1,32 +1,15 @@
|
|||||||
import { useEffect, useRef, type RefObject } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { pdfjs, type PdfDocumentProxy } from "@/utils/pdfjsSetup";
|
import type { PdfDocumentProxy } from "@/utils/pdfjsSetup";
|
||||||
import type { PdfRect } from "@/utils/pdfAnchor";
|
|
||||||
import { highlightBackgroundStyle } from "@/constants/bookmarkHighlightColors";
|
|
||||||
|
|
||||||
interface PdfHighlightOverlay {
|
|
||||||
rects: PdfRect[];
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PdfViewerProps {
|
interface PdfViewerProps {
|
||||||
document: PdfDocumentProxy;
|
document: PdfDocumentProxy;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
highlights?: PdfHighlightOverlay[];
|
|
||||||
pageWrapRef?: RefObject<HTMLDivElement | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfViewer({
|
export function PdfViewer({ document, pageNumber, scale }: PdfViewerProps) {
|
||||||
document,
|
|
||||||
pageNumber,
|
|
||||||
scale,
|
|
||||||
highlights = [],
|
|
||||||
pageWrapRef,
|
|
||||||
}: PdfViewerProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const internalWrapRef = useRef<HTMLDivElement>(null);
|
|
||||||
const wrapRef = pageWrapRef ?? internalWrapRef;
|
|
||||||
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
|
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,8 +22,7 @@ export function PdfViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const textLayer = textLayerRef.current;
|
if (!canvas) return;
|
||||||
if (!canvas || !textLayer) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await document.getPage(pageNumber);
|
const page = await document.getPage(pageNumber);
|
||||||
@@ -49,9 +31,6 @@ export function PdfViewer({
|
|||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
textLayer.style.width = `${viewport.width}px`;
|
|
||||||
textLayer.style.height = `${viewport.height}px`;
|
|
||||||
textLayer.innerHTML = "";
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -59,27 +38,8 @@ export function PdfViewer({
|
|||||||
const renderTask = page.render({ canvasContext: ctx, viewport });
|
const renderTask = page.render({ canvasContext: ctx, viewport });
|
||||||
renderTaskRef.current = renderTask;
|
renderTaskRef.current = renderTask;
|
||||||
await renderTask.promise;
|
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 {
|
} catch {
|
||||||
if (!cancelled) {
|
/* render failed */
|
||||||
textLayerRef.current && (textLayerRef.current.innerHTML = "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,27 +57,6 @@ export function PdfViewer({
|
|||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className="pdf-page-wrap">
|
<div ref={wrapRef} className="pdf-page-wrap">
|
||||||
<canvas ref={canvasRef} className="pdf-page-canvas" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface ReaderToolbarProps {
|
|||||||
onToggleToc: () => void;
|
onToggleToc: () => void;
|
||||||
onToggleSettings: () => void;
|
onToggleSettings: () => void;
|
||||||
onToggleMarkers?: () => void;
|
onToggleMarkers?: () => void;
|
||||||
|
onBookmarkPage?: () => void;
|
||||||
|
/** PDF: filled bookmark when the current page is already bookmarked */
|
||||||
|
isCurrentPageBookmarked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReaderToolbar({
|
export default function ReaderToolbar({
|
||||||
@@ -23,6 +26,8 @@ export default function ReaderToolbar({
|
|||||||
onToggleToc,
|
onToggleToc,
|
||||||
onToggleSettings,
|
onToggleSettings,
|
||||||
onToggleMarkers,
|
onToggleMarkers,
|
||||||
|
onBookmarkPage,
|
||||||
|
isCurrentPageBookmarked = false,
|
||||||
}: ReaderToolbarProps) {
|
}: ReaderToolbarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const percentage = progress?.percentage ?? 0;
|
const percentage = progress?.percentage ?? 0;
|
||||||
@@ -74,6 +79,26 @@ export default function ReaderToolbar({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onBookmarkPage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reader-bar-btn"
|
||||||
|
onClick={onBookmarkPage}
|
||||||
|
aria-label={t("reader.bookmarkPageAria")}
|
||||||
|
aria-pressed={isCurrentPageBookmarked}
|
||||||
|
title={t("reader.bookmarkPage")}
|
||||||
|
>
|
||||||
|
{isCurrentPageBookmarked ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onToggleMarkers && (
|
{onToggleMarkers && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,9 +106,15 @@ export default function ReaderToolbar({
|
|||||||
onClick={onToggleMarkers}
|
onClick={onToggleMarkers}
|
||||||
aria-label={t("annotations.inBookPanel")}
|
aria-label={t("annotations.inBookPanel")}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
{onBookmarkPage ? (
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
</svg>
|
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ export interface UsePdfReaderReturn {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
isBookmarkPeekActive: boolean;
|
isBookmarkPeekActive: boolean;
|
||||||
readingAnchor: PdfReadingAnchor | null;
|
readingAnchor: PdfReadingAnchor | null;
|
||||||
pageWrapRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
goToPage: (page: number) => void;
|
goToPage: (page: number) => void;
|
||||||
goToNextPage: () => void;
|
goToNextPage: () => void;
|
||||||
goToPrevPage: () => void;
|
goToPrevPage: () => void;
|
||||||
@@ -55,7 +54,6 @@ export function usePdfReader(
|
|||||||
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
|
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
|
||||||
const [readingAnchor, setReadingAnchor] = useState<PdfReadingAnchor | null>(null);
|
const [readingAnchor, setReadingAnchor] = useState<PdfReadingAnchor | null>(null);
|
||||||
|
|
||||||
const pageWrapRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isBookmarkPeekRef = useRef(false);
|
const isBookmarkPeekRef = useRef(false);
|
||||||
const readingAnchorRef = useRef<PdfReadingAnchor | null>(null);
|
const readingAnchorRef = useRef<PdfReadingAnchor | null>(null);
|
||||||
@@ -247,7 +245,6 @@ export function usePdfReader(
|
|||||||
error,
|
error,
|
||||||
isBookmarkPeekActive,
|
isBookmarkPeekActive,
|
||||||
readingAnchor,
|
readingAnchor,
|
||||||
pageWrapRef,
|
|
||||||
goToPage,
|
goToPage,
|
||||||
goToNextPage,
|
goToNextPage,
|
||||||
goToPrevPage,
|
goToPrevPage,
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -125,8 +125,12 @@ const enUS = {
|
|||||||
pdfZoomAria: "PDF zoom level",
|
pdfZoomAria: "PDF zoom level",
|
||||||
pdfLimitationsTitle: "PDF reading",
|
pdfLimitationsTitle: "PDF reading",
|
||||||
pdfLimitationsBody:
|
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.",
|
"PDF reading uses page bookmarks (filled bookmark icon in the toolbar). Passage highlights need selectable text and are not available on scanned pages. The table of contents lists pages, not EPUB-style chapters.",
|
||||||
pdfLimitationsDismissAria: "Dismiss PDF limitations notice",
|
pdfLimitationsDismissAria: "Dismiss PDF limitations notice",
|
||||||
|
bookmarkPage: "Bookmark this page",
|
||||||
|
bookmarkPageAria: "Bookmark current page",
|
||||||
|
pageBookmarked: "Page bookmarked",
|
||||||
|
pageAlreadyBookmarked: "This page is already bookmarked",
|
||||||
backToLibrary: "Back to library",
|
backToLibrary: "Back to library",
|
||||||
prevPage: "Previous page",
|
prevPage: "Previous page",
|
||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
@@ -217,6 +221,7 @@ const enUS = {
|
|||||||
highlightBlue: "Blue",
|
highlightBlue: "Blue",
|
||||||
highlightPurple: "Purple",
|
highlightPurple: "Purple",
|
||||||
selectTextHint: "Select text in the reader to add a bookmark or note.",
|
selectTextHint: "Select text in the reader to add a bookmark or note.",
|
||||||
|
pdfPageBookmarkHint: "Use the filled bookmark button in the toolbar to save the current page.",
|
||||||
showMore: "Show more",
|
showMore: "Show more",
|
||||||
showLess: "Show less",
|
showLess: "Show less",
|
||||||
goToPassage: "Go to passage",
|
goToPassage: "Go to passage",
|
||||||
|
|||||||
@@ -127,8 +127,12 @@ const esES: Locale = {
|
|||||||
pdfZoomAria: "Nivel de zoom del PDF",
|
pdfZoomAria: "Nivel de zoom del PDF",
|
||||||
pdfLimitationsTitle: "Lectura en PDF",
|
pdfLimitationsTitle: "Lectura en PDF",
|
||||||
pdfLimitationsBody:
|
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.",
|
"En PDF puedes guardar marcadores de página (icono de marcador relleno en la barra). Los resaltados de pasaje requieren texto seleccionable y no están disponibles en páginas escaneadas. La tabla de contenidos muestra páginas, no capítulos como en EPUB.",
|
||||||
pdfLimitationsDismissAria: "Cerrar aviso de limitaciones del PDF",
|
pdfLimitationsDismissAria: "Cerrar aviso de limitaciones del PDF",
|
||||||
|
bookmarkPage: "Marcar esta página",
|
||||||
|
bookmarkPageAria: "Marcar la página actual",
|
||||||
|
pageBookmarked: "Página marcada",
|
||||||
|
pageAlreadyBookmarked: "Esta página ya está marcada",
|
||||||
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",
|
||||||
@@ -219,6 +223,7 @@ const esES: Locale = {
|
|||||||
highlightBlue: "Azul",
|
highlightBlue: "Azul",
|
||||||
highlightPurple: "Morado",
|
highlightPurple: "Morado",
|
||||||
selectTextHint: "Selecciona texto en el lector para añadir un marcador o nota.",
|
selectTextHint: "Selecciona texto en el lector para añadir un marcador o nota.",
|
||||||
|
pdfPageBookmarkHint: "Usa el botón de marcador relleno en la barra para guardar la página actual.",
|
||||||
showMore: "Ver más",
|
showMore: "Ver más",
|
||||||
showLess: "Ver menos",
|
showLess: "Ver menos",
|
||||||
goToPassage: "Ir al pasaje",
|
goToPassage: "Ir al pasaje",
|
||||||
|
|||||||
@@ -226,11 +226,19 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
max-height: min(45vh, 360px);
|
max-height: min(45vh, 360px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: visible;
|
overflow-x: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-bookmark-strip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-bookmark-marker-wrap {
|
.reader-bookmark-marker-wrap {
|
||||||
|
|||||||
Reference in New Issue
Block a user