fix: pdf bookmark

for pdfs only select pages not text
This commit is contained in:
2026-06-03 23:01:12 -05:00
parent f091386974
commit f989b144cf
11 changed files with 111 additions and 198 deletions
@@ -12,6 +12,7 @@ interface BookMarkersPanelProps {
isOpen: boolean;
onClose: () => void;
onGoToPassage: (marker: MarkerEntry) => void;
emptyHintKey?: string;
}
export function BookMarkersPanel({
@@ -19,6 +20,7 @@ export function BookMarkersPanel({
isOpen,
onClose,
onGoToPassage,
emptyHintKey = "annotations.selectTextHint",
}: BookMarkersPanelProps) {
const { t } = useTranslation();
const { markers, loadBookmarks, removeBookmark, state } = useAnnotations();
@@ -43,7 +45,7 @@ export function BookMarkersPanel({
{state.bookmarksLoading ? (
<p className="annotations-loading">{t("annotations.loading")}</p>
) : markers.length === 0 ? (
<p className="annotations-empty">{t("annotations.selectTextHint")}</p>
<p className="annotations-empty">{t(emptyHintKey)}</p>
) : (
<ul className="marker-thread-list">
{markers.map((m) => (
@@ -51,13 +53,15 @@ export function BookMarkersPanel({
{m.chapter_title && (
<span className="marker-thread-chapter">{m.chapter_title}</span>
)}
{m.location_text && (
{m.location_text ? (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
className="annotation-quote"
/>
)}
) : m.chapter_title ? (
<span className="annotation-quote marker-page-label">{m.chapter_title}</span>
) : null}
{m.content ? (
<CollapsibleMarkerThought text={m.content} />
) : (
@@ -20,7 +20,9 @@ function RailMarker({
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
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
? truncateRailPreview(item.marker.content, 80)
: "";
@@ -7,16 +7,14 @@ 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 { useToast } from "../../hooks/useToast";
import { booksApi } from "../../api/books";
import type { EBookDetail } from "../../types/book";
import type { EpubTocItem } from "./TableOfContents";
import { parsePageHref } from "../../utils/pdfAnchor";
import { parsePageHref, serializePdfAnchor } from "../../utils/pdfAnchor";
import { fallbackPdfPageToc } from "../../utils/pdfToc";
import { SelectionPopover } from "./SelectionPopover";
import { BookMarkersPanel } from "./BookMarkersPanel";
import { BookmarkReaderRail } from "./BookmarkReaderRail";
import { ResumeReadingButton } from "./ResumeReadingButton";
@@ -37,6 +35,8 @@ interface PdfReadingViewProps {
export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const { markers, addMarker, loadBookmarks } = useAnnotations();
const [tocOpen, setTocOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -57,7 +57,6 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
error,
isBookmarkPeekActive,
readingAnchor,
pageWrapRef,
goToPage,
goToNextPage,
goToPrevPage,
@@ -66,21 +65,18 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
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(() => {
if (!bookId || Number.isNaN(bookId)) return;
void loadBookmarks(bookId);
}, [bookId, loadBookmarks]);
useEffect(() => {
void booksApi
.getToc(bookId)
@@ -129,6 +125,29 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
[beginBookmarkPeek],
);
const handleBookmarkPage = useCallback(() => {
const anchor = serializePdfAnchor(currentPage);
const exists = bookMarkers.some((m) => m.epub_cfi === anchor);
if (exists) {
showToast({ message: t("reader.pageAlreadyBookmarked"), variant: "warning" });
return;
}
void addMarker({
ebook: bookId,
epub_cfi: anchor,
chapter_index: currentPage - 1,
chapter_title: t("reader.pdfPageLabel", { page: String(currentPage) }),
location_text: "",
content: "",
})
.then(() => {
showToast({ message: t("reader.pageBookmarked"), variant: "success" });
})
.catch(() => {
showToast({ message: t("annotations.saveFailed"), variant: "error" });
});
}, [addMarker, bookId, bookMarkers, currentPage, showToast, t]);
const progressForToolbar = useMemo(() => {
if (!progress) return progress;
const pct = pageCount > 0 ? Math.round((currentPage / pageCount) * 100) : 0;
@@ -140,6 +159,11 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
};
}, [progress, currentPage, pageCount]);
const isCurrentPageBookmarked = useMemo(() => {
const anchor = serializePdfAnchor(currentPage);
return bookMarkers.some((m) => m.epub_cfi === anchor);
}, [bookMarkers, currentPage]);
if (isLoading) {
return (
<div className="reader-loading">
@@ -179,6 +203,8 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
onToggleToc={() => setTocOpen((v) => !v)}
onToggleSettings={() => setSettingsOpen((v) => !v)}
onToggleMarkers={() => setMarkersOpen((v) => !v)}
onBookmarkPage={handleBookmarkPage}
isCurrentPageBookmarked={isCurrentPageBookmarked}
/>
<PdfLimitationsNotice bookId={bookId} />
@@ -195,6 +221,7 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
isOpen={markersOpen}
onClose={() => setMarkersOpen(false)}
onGoToPassage={handleGoToMarker}
emptyHintKey="annotations.pdfPageBookmarkHint"
/>
<ReadingSettingsPanel
@@ -208,23 +235,12 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
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
+6 -67
View File
@@ -1,32 +1,15 @@
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;
}
import { useEffect, useRef } from "react";
import type { PdfDocumentProxy } from "@/utils/pdfjsSetup";
interface PdfViewerProps {
document: PdfDocumentProxy;
pageNumber: number;
scale: number;
highlights?: PdfHighlightOverlay[];
pageWrapRef?: RefObject<HTMLDivElement | null>;
}
export function PdfViewer({
document,
pageNumber,
scale,
highlights = [],
pageWrapRef,
}: PdfViewerProps) {
export function PdfViewer({ document, pageNumber, scale }: PdfViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const textLayerRef = useRef<HTMLDivElement>(null);
const internalWrapRef = useRef<HTMLDivElement>(null);
const wrapRef = pageWrapRef ?? internalWrapRef;
const wrapRef = useRef<HTMLDivElement>(null);
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
useEffect(() => {
@@ -39,8 +22,7 @@ export function PdfViewer({
}
const canvas = canvasRef.current;
const textLayer = textLayerRef.current;
if (!canvas || !textLayer) return;
if (!canvas) return;
try {
const page = await document.getPage(pageNumber);
@@ -49,9 +31,6 @@ export function PdfViewer({
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;
@@ -59,27 +38,8 @@ export function PdfViewer({
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 = "");
}
/* render failed */
}
}
@@ -97,27 +57,6 @@ export function PdfViewer({
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>
);
}
@@ -13,6 +13,9 @@ interface ReaderToolbarProps {
onToggleToc: () => void;
onToggleSettings: () => void;
onToggleMarkers?: () => void;
onBookmarkPage?: () => void;
/** PDF: filled bookmark when the current page is already bookmarked */
isCurrentPageBookmarked?: boolean;
}
export default function ReaderToolbar({
@@ -23,6 +26,8 @@ export default function ReaderToolbar({
onToggleToc,
onToggleSettings,
onToggleMarkers,
onBookmarkPage,
isCurrentPageBookmarked = false,
}: ReaderToolbarProps) {
const { t } = useTranslation();
const percentage = progress?.percentage ?? 0;
@@ -74,6 +79,26 @@ export default function ReaderToolbar({
</svg>
</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 && (
<button
type="button"
@@ -81,9 +106,15 @@ export default function ReaderToolbar({
onClick={onToggleMarkers}
aria-label={t("annotations.inBookPanel")}
>
{onBookmarkPage ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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
-37
View File
@@ -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]);
}
-3
View File
@@ -26,7 +26,6 @@ export interface UsePdfReaderReturn {
error: string | null;
isBookmarkPeekActive: boolean;
readingAnchor: PdfReadingAnchor | null;
pageWrapRef: React.RefObject<HTMLDivElement | null>;
goToPage: (page: number) => void;
goToNextPage: () => void;
goToPrevPage: () => void;
@@ -55,7 +54,6 @@ export function usePdfReader(
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);
@@ -247,7 +245,6 @@ export function usePdfReader(
error,
isBookmarkPeekActive,
readingAnchor,
pageWrapRef,
goToPage,
goToNextPage,
goToPrevPage,
-57
View File
@@ -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 };
}
+6 -1
View File
@@ -125,8 +125,12 @@ const enUS = {
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.",
"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",
bookmarkPage: "Bookmark this page",
bookmarkPageAria: "Bookmark current page",
pageBookmarked: "Page bookmarked",
pageAlreadyBookmarked: "This page is already bookmarked",
backToLibrary: "Back to library",
prevPage: "Previous page",
nextPage: "Next page",
@@ -217,6 +221,7 @@ const enUS = {
highlightBlue: "Blue",
highlightPurple: "Purple",
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",
showLess: "Show less",
goToPassage: "Go to passage",
+6 -1
View File
@@ -127,8 +127,12 @@ const esES: Locale = {
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.",
"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",
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",
prevPage: "Página anterior",
nextPage: "Página siguiente",
@@ -219,6 +223,7 @@ const esES: Locale = {
highlightBlue: "Azul",
highlightPurple: "Morado",
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",
showLess: "Ver menos",
goToPassage: "Ir al pasaje",
+9 -1
View File
@@ -226,11 +226,19 @@
gap: 4px;
max-height: min(45vh, 360px);
overflow-y: auto;
overflow-x: visible;
overflow-x: hidden;
margin: 0;
padding: 0;
list-style: 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 {