diff --git a/frontend/src/components/reader/BookmarkIcon.tsx b/frontend/src/components/reader/BookmarkIcon.tsx new file mode 100644 index 0000000..a732219 --- /dev/null +++ b/frontend/src/components/reader/BookmarkIcon.tsx @@ -0,0 +1,26 @@ +/** Same bookmark ribbon as ReaderToolbar, for rail markers (use with rotate for horizontal). */ + +interface BookmarkIconProps { + size?: number; + color: string; + className?: string; +} + +export function BookmarkIcon({ size = 24, color, className }: BookmarkIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/reader/BookmarkReaderRail.tsx b/frontend/src/components/reader/BookmarkReaderRail.tsx new file mode 100644 index 0000000..16073e7 --- /dev/null +++ b/frontend/src/components/reader/BookmarkReaderRail.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18n-lite"; +import type { BookmarkRailItem } from "@/utils/bookmarkRailLayout"; +import { truncateRailPreview } from "@/utils/bookmarkRailLayout"; +import { normalizeHighlightColor } from "@/constants/bookmarkHighlightColors"; +import { BookmarkIcon } from "./BookmarkIcon"; + +interface BookmarkReaderRailProps { + items: BookmarkRailItem[]; + onGoToBookmark: (epubCfi: string) => void; +} + +function RailMarker({ + item, + onGoToBookmark, +}: { + item: BookmarkRailItem; + onGoToBookmark: (epubCfi: string) => void; +}) { + const { t } = useTranslation(); + const [hovered, setHovered] = useState(false); + const color = normalizeHighlightColor(item.marker.highlight_color); + const previewText = truncateRailPreview(item.marker.location_text || ""); + const notePreview = item.marker.content + ? truncateRailPreview(item.marker.content, 80) + : ""; + + const ariaLabel = previewText + ? t("reader.bookmarkRailPreview", { text: previewText }) + : t("reader.bookmarkRailGoTo"); + + return ( +
  • + + {hovered && previewText && ( +
    +

    {previewText}

    + {notePreview && ( +

    {notePreview}

    + )} +
    + )} +
  • + ); +} + +export function BookmarkReaderRail({ items, onGoToBookmark }: BookmarkReaderRailProps) { + if (items.length === 0) return null; + + return ( + + ); +} diff --git a/frontend/src/hooks/useBookmarkRailLayout.ts b/frontend/src/hooks/useBookmarkRailLayout.ts new file mode 100644 index 0000000..3058e01 --- /dev/null +++ b/frontend/src/hooks/useBookmarkRailLayout.ts @@ -0,0 +1,7 @@ +import { useMemo } from "react"; +import type { MarkerEntry } from "@/types"; +import { layoutBookmarkRail, type BookmarkRailItem } from "@/utils/bookmarkRailLayout"; + +export function useBookmarkRailLayout(markers: MarkerEntry[]): BookmarkRailItem[] { + return useMemo(() => layoutBookmarkRail(markers), [markers]); +} diff --git a/frontend/src/hooks/useEpubReader.ts b/frontend/src/hooks/useEpubReader.ts index bd33a13..9cd1a07 100644 --- a/frontend/src/hooks/useEpubReader.ts +++ b/frontend/src/hooks/useEpubReader.ts @@ -10,6 +10,7 @@ import { applyRenditionSettings, isEpubCfi, marginWidthToGap, + percentageFromCfi, resolveSpineHref, updateChapterTitleFromRendition, type EpubRendition, @@ -28,13 +29,6 @@ export interface ReadingAnchor { percentage: number; } -function percentageFromCfi(rendition: EpubRendition, cfi: string): number { - if (!rendition.book?.locations) return 0; - const pct = rendition.book.locations.percentageFromCfi(cfi); - if (pct === null || pct === undefined) return 0; - return Math.round(pct * 100); -} - function cfiFromRendition(rendition: EpubRendition | null): string | null { if (!rendition) return null; const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location @@ -157,7 +151,7 @@ export function useEpubReader( cfi = cfiFromRendition(rendition); } if (!cfi) return null; - const percentage = rendition ? percentageFromCfi(rendition, cfi) : 0; + const percentage = rendition ? (percentageFromCfi(rendition, cfi) ?? 0) : 0; return { cfi, percentage }; }, [location]); @@ -187,7 +181,7 @@ export function useEpubReader( setLocation(anchor.cfi); const rendition = renditionRef.current; - const pct = rendition ? percentageFromCfi(rendition, anchor.cfi) : anchor.percentage; + const pct = rendition ? (percentageFromCfi(rendition, anchor.cfi) ?? anchor.percentage) : anchor.percentage; void flushProgress(anchor.cfi, pct > 0 ? pct : anchor.percentage); }, [clearPeekState, flushProgress]); @@ -267,7 +261,7 @@ export function useEpubReader( if (!isEpubCfi(loc)) return; const rendition = renditionRef.current; - const percentage = rendition ? percentageFromCfi(rendition, loc) : 0; + const percentage = rendition ? (percentageFromCfi(rendition, loc) ?? 0) : 0; if (rendition) { void updateChapterTitleFromRendition(rendition).then((title) => { @@ -306,7 +300,7 @@ export function useEpubReader( const cfi = start?.cfi; if (!cfi || !isEpubCfi(cfi)) return; - const percentage = percentageFromCfi(rendition, cfi); + const percentage = percentageFromCfi(rendition, cfi) ?? 0; void flushProgress(cfi, percentage); if (percentage <= 0 && attempt < 2) { diff --git a/frontend/src/locales/en-US.ts b/frontend/src/locales/en-US.ts index 70d5cda..54f89d0 100644 --- a/frontend/src/locales/en-US.ts +++ b/frontend/src/locales/en-US.ts @@ -123,6 +123,8 @@ const enUS = { nextPage: "Next page", resumeReading: "Back to reading", resumeReadingAria: "Return to where you were reading", + bookmarkRailGoTo: "Go to bookmark", + bookmarkRailPreview: "Bookmark: {{text}}", backToLibraryAria: "Back to library", tocAria: "Table of contents", settingsAria: "Reading settings", diff --git a/frontend/src/locales/es-ES.ts b/frontend/src/locales/es-ES.ts index 8b14cad..a0a8898 100644 --- a/frontend/src/locales/es-ES.ts +++ b/frontend/src/locales/es-ES.ts @@ -125,6 +125,8 @@ const esES: Locale = { nextPage: "Página siguiente", resumeReading: "Volver a la lectura", resumeReadingAria: "Volver a donde estabas leyendo", + bookmarkRailGoTo: "Ir al marcador", + bookmarkRailPreview: "Marcador: {{text}}", backToLibraryAria: "Volver a la biblioteca", tocAria: "Tabla de contenidos", settingsAria: "Ajustes de lectura", diff --git a/frontend/src/pages/ReadingPage.tsx b/frontend/src/pages/ReadingPage.tsx index a04f091..fd98397 100644 --- a/frontend/src/pages/ReadingPage.tsx +++ b/frontend/src/pages/ReadingPage.tsx @@ -2,11 +2,13 @@ * ReadingPage — full-screen EPUB reading view powered by react-reader (epub.js). */ -import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, 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"; @@ -15,6 +17,7 @@ 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"; @@ -73,7 +76,14 @@ export default function ReadingPage() { 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; @@ -227,6 +237,10 @@ export default function ReadingPage() { } /> +