feat: floating bookmarks

add floating bookmarks
This commit is contained in:
2026-06-03 22:19:19 -05:00
parent 6b4c0c43f8
commit 054e7689bd
10 changed files with 263 additions and 12 deletions
@@ -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 (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
);
}
@@ -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 (
<li className="reader-bookmark-marker-wrap">
<button
type="button"
className="reader-bookmark-marker"
onClick={() => onGoToBookmark(item.marker.epub_cfi)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
aria-label={ariaLabel}
title={previewText || undefined}
>
<BookmarkIcon size={28} color={color} className="reader-bookmark-marker-icon" />
</button>
{hovered && previewText && (
<div
className="reader-bookmark-preview"
style={{ borderLeftColor: color }}
role="tooltip"
>
<p className="reader-bookmark-preview-passage">{previewText}</p>
{notePreview && (
<p className="reader-bookmark-preview-note">{notePreview}</p>
)}
</div>
)}
</li>
);
}
export function BookmarkReaderRail({ items, onGoToBookmark }: BookmarkReaderRailProps) {
if (items.length === 0) return null;
return (
<ul className="reader-bookmark-strip" aria-label="Bookmarks">
{items.map((item) => (
<RailMarker key={item.marker.id} item={item} onGoToBookmark={onGoToBookmark} />
))}
</ul>
);
}
@@ -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]);
}
+5 -11
View File
@@ -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) {
+2
View File
@@ -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",
+2
View File
@@ -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",
+15 -1
View File
@@ -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() {
</div>
}
/>
<BookmarkReaderRail
items={railItems}
onGoToBookmark={beginBookmarkPeek}
/>
<button
type="button"
className="reader-page-nav reader-page-nav--prev"
+99
View File
@@ -215,6 +215,105 @@
display: block;
}
.reader-bookmark-strip {
position: absolute;
top: 8px;
right: 52px;
z-index: 9;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
max-height: min(45vh, 360px);
overflow-y: auto;
overflow-x: visible;
margin: 0;
padding: 0;
list-style: none;
pointer-events: none;
}
.reader-bookmark-marker-wrap {
position: relative;
flex-shrink: 0;
pointer-events: auto;
}
.reader-bookmark-marker {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 4px;
border: none;
background: transparent;
cursor: pointer;
opacity: 0.55;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
transition: opacity 0.15s ease, transform 0.15s ease;
}
.reader-bookmark-marker-icon {
transform: rotate(-90deg);
display: block;
}
.reader-bookmark-marker:hover,
.reader-bookmark-marker:focus {
opacity: 1;
transform: scale(1.15);
outline: none;
}
.reader-bookmark-preview {
position: absolute;
right: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
width: max-content;
max-width: 220px;
padding: 8px 10px;
background: #fff;
border-left: 4px solid #fde047;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
z-index: 20;
pointer-events: none;
}
.reader-bookmark-preview-passage {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: #374151;
font-style: italic;
}
.reader-bookmark-preview-note {
margin: 6px 0 0;
font-size: 0.6875rem;
line-height: 1.35;
color: #6b7280;
}
.reader-container[data-theme="dark"] .reader-bookmark-preview {
background: #2a2a2a;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.reader-container[data-theme="dark"] .reader-bookmark-preview-passage {
color: #e5e7eb;
}
.reader-container[data-theme="dark"] .reader-bookmark-preview-note {
color: #9ca3af;
}
@media (max-width: 640px) {
.reader-bookmark-strip {
display: none;
}
}
.reader-container[data-theme="dark"] .reader-page-nav {
color: rgba(102, 102, 102, 0.9);
}
+17
View File
@@ -0,0 +1,17 @@
import type { MarkerEntry } from "@/types";
export interface BookmarkRailItem {
marker: MarkerEntry;
}
export function layoutBookmarkRail(markers: MarkerEntry[]): BookmarkRailItem[] {
return [...markers]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map((marker) => ({ marker }));
}
export function truncateRailPreview(text: string, max = 120): string {
const t = text.trim();
if (t.length <= max) return t;
return `${t.slice(0, max)}`;
}
+17
View File
@@ -55,6 +55,23 @@ export function isEpubCfi(location: string | number): boolean {
return typeof location === "string" && location.startsWith("epubcfi(");
}
/** Book progress 0100 from an EPUB CFI (requires locations.generate). */
export function percentageFromCfi(rendition: EpubRendition, cfi: string): number | null {
if (!rendition.book?.locations || !isEpubCfi(cfi)) return null;
try {
const pct = rendition.book.locations.percentageFromCfi(cfi);
if (pct === null || pct === undefined) return null;
return Math.min(100, Math.max(0, Math.round(pct * 100)));
} catch {
return null;
}
}
export function spineLength(rendition: EpubRendition): number {
const items = rendition.book?.spine?.spineItems;
return items?.length ?? 0;
}
/** Map nav/TOC href to a spine href epub.js can display. */
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
const hashIndex = href.indexOf("#");