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 (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
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() {
}
/>
+