Archived
feat: floating bookmarks
add floating bookmarks
This commit is contained in:
@@ -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]);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
applyRenditionSettings,
|
applyRenditionSettings,
|
||||||
isEpubCfi,
|
isEpubCfi,
|
||||||
marginWidthToGap,
|
marginWidthToGap,
|
||||||
|
percentageFromCfi,
|
||||||
resolveSpineHref,
|
resolveSpineHref,
|
||||||
updateChapterTitleFromRendition,
|
updateChapterTitleFromRendition,
|
||||||
type EpubRendition,
|
type EpubRendition,
|
||||||
@@ -28,13 +29,6 @@ export interface ReadingAnchor {
|
|||||||
percentage: number;
|
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 {
|
function cfiFromRendition(rendition: EpubRendition | null): string | null {
|
||||||
if (!rendition) return null;
|
if (!rendition) return null;
|
||||||
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
|
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
|
||||||
@@ -157,7 +151,7 @@ export function useEpubReader(
|
|||||||
cfi = cfiFromRendition(rendition);
|
cfi = cfiFromRendition(rendition);
|
||||||
}
|
}
|
||||||
if (!cfi) return null;
|
if (!cfi) return null;
|
||||||
const percentage = rendition ? percentageFromCfi(rendition, cfi) : 0;
|
const percentage = rendition ? (percentageFromCfi(rendition, cfi) ?? 0) : 0;
|
||||||
return { cfi, percentage };
|
return { cfi, percentage };
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
@@ -187,7 +181,7 @@ export function useEpubReader(
|
|||||||
|
|
||||||
setLocation(anchor.cfi);
|
setLocation(anchor.cfi);
|
||||||
const rendition = renditionRef.current;
|
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);
|
void flushProgress(anchor.cfi, pct > 0 ? pct : anchor.percentage);
|
||||||
}, [clearPeekState, flushProgress]);
|
}, [clearPeekState, flushProgress]);
|
||||||
|
|
||||||
@@ -267,7 +261,7 @@ export function useEpubReader(
|
|||||||
if (!isEpubCfi(loc)) return;
|
if (!isEpubCfi(loc)) return;
|
||||||
|
|
||||||
const rendition = renditionRef.current;
|
const rendition = renditionRef.current;
|
||||||
const percentage = rendition ? percentageFromCfi(rendition, loc) : 0;
|
const percentage = rendition ? (percentageFromCfi(rendition, loc) ?? 0) : 0;
|
||||||
|
|
||||||
if (rendition) {
|
if (rendition) {
|
||||||
void updateChapterTitleFromRendition(rendition).then((title) => {
|
void updateChapterTitleFromRendition(rendition).then((title) => {
|
||||||
@@ -306,7 +300,7 @@ export function useEpubReader(
|
|||||||
const cfi = start?.cfi;
|
const cfi = start?.cfi;
|
||||||
if (!cfi || !isEpubCfi(cfi)) return;
|
if (!cfi || !isEpubCfi(cfi)) return;
|
||||||
|
|
||||||
const percentage = percentageFromCfi(rendition, cfi);
|
const percentage = percentageFromCfi(rendition, cfi) ?? 0;
|
||||||
void flushProgress(cfi, percentage);
|
void flushProgress(cfi, percentage);
|
||||||
|
|
||||||
if (percentage <= 0 && attempt < 2) {
|
if (percentage <= 0 && attempt < 2) {
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ const enUS = {
|
|||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
resumeReading: "Back to reading",
|
resumeReading: "Back to reading",
|
||||||
resumeReadingAria: "Return to where you were reading",
|
resumeReadingAria: "Return to where you were reading",
|
||||||
|
bookmarkRailGoTo: "Go to bookmark",
|
||||||
|
bookmarkRailPreview: "Bookmark: {{text}}",
|
||||||
backToLibraryAria: "Back to library",
|
backToLibraryAria: "Back to library",
|
||||||
tocAria: "Table of contents",
|
tocAria: "Table of contents",
|
||||||
settingsAria: "Reading settings",
|
settingsAria: "Reading settings",
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ const esES: Locale = {
|
|||||||
nextPage: "Página siguiente",
|
nextPage: "Página siguiente",
|
||||||
resumeReading: "Volver a la lectura",
|
resumeReading: "Volver a la lectura",
|
||||||
resumeReadingAria: "Volver a donde estabas leyendo",
|
resumeReadingAria: "Volver a donde estabas leyendo",
|
||||||
|
bookmarkRailGoTo: "Ir al marcador",
|
||||||
|
bookmarkRailPreview: "Marcador: {{text}}",
|
||||||
backToLibraryAria: "Volver a la biblioteca",
|
backToLibraryAria: "Volver a la biblioteca",
|
||||||
tocAria: "Tabla de contenidos",
|
tocAria: "Tabla de contenidos",
|
||||||
settingsAria: "Ajustes de lectura",
|
settingsAria: "Ajustes de lectura",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
* ReadingPage — full-screen EPUB reading view powered by react-reader (epub.js).
|
* 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 { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18n-lite";
|
import { useTranslation } from "react-i18n-lite";
|
||||||
import { EpubView, EpubViewStyle } from "react-reader";
|
import { EpubView, EpubViewStyle } from "react-reader";
|
||||||
import { booksApi } from "../api/books";
|
import { booksApi } from "../api/books";
|
||||||
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||||
|
import { useBookmarkRailLayout } from "../hooks/useBookmarkRailLayout";
|
||||||
import { useEpubHighlights } from "../hooks/useEpubHighlights";
|
import { useEpubHighlights } from "../hooks/useEpubHighlights";
|
||||||
import { useEpubReader } from "../hooks/useEpubReader";
|
import { useEpubReader } from "../hooks/useEpubReader";
|
||||||
import { useEpubSelection } from "../hooks/useEpubSelection";
|
import { useEpubSelection } from "../hooks/useEpubSelection";
|
||||||
@@ -15,6 +17,7 @@ import type { EBookDetail } from "../types/book";
|
|||||||
import type { EpubTocItem } from "../components/reader/TableOfContents";
|
import type { EpubTocItem } from "../components/reader/TableOfContents";
|
||||||
import { SelectionPopover } from "../components/reader/SelectionPopover";
|
import { SelectionPopover } from "../components/reader/SelectionPopover";
|
||||||
import { BookMarkersPanel } from "../components/reader/BookMarkersPanel";
|
import { BookMarkersPanel } from "../components/reader/BookMarkersPanel";
|
||||||
|
import { BookmarkReaderRail } from "../components/reader/BookmarkReaderRail";
|
||||||
import { ResumeReadingButton } from "../components/reader/ResumeReadingButton";
|
import { ResumeReadingButton } from "../components/reader/ResumeReadingButton";
|
||||||
import type { MarkerEntry } from "@/types";
|
import type { MarkerEntry } from "@/types";
|
||||||
import "../reader.css";
|
import "../reader.css";
|
||||||
@@ -73,7 +76,14 @@ export default function ReadingPage() {
|
|||||||
renditionVersion,
|
renditionVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { markers } = useAnnotations();
|
||||||
|
const bookMarkers = useMemo(
|
||||||
|
() => markers.filter((m) => m.ebook_id === bookId),
|
||||||
|
[markers, bookId],
|
||||||
|
);
|
||||||
|
|
||||||
useEpubHighlights(bookId, renditionRef, renditionVersion);
|
useEpubHighlights(bookId, renditionRef, renditionVersion);
|
||||||
|
const railItems = useBookmarkRailLayout(bookMarkers);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bookId || Number.isNaN(bookId)) return;
|
if (!bookId || Number.isNaN(bookId)) return;
|
||||||
@@ -227,6 +237,10 @@ export default function ReadingPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<BookmarkReaderRail
|
||||||
|
items={railItems}
|
||||||
|
onGoToBookmark={beginBookmarkPeek}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="reader-page-nav reader-page-nav--prev"
|
className="reader-page-nav reader-page-nav--prev"
|
||||||
|
|||||||
@@ -215,6 +215,105 @@
|
|||||||
display: block;
|
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 {
|
.reader-container[data-theme="dark"] .reader-page-nav {
|
||||||
color: rgba(102, 102, 102, 0.9);
|
color: rgba(102, 102, 102, 0.9);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}…`;
|
||||||
|
}
|
||||||
@@ -55,6 +55,23 @@ export function isEpubCfi(location: string | number): boolean {
|
|||||||
return typeof location === "string" && location.startsWith("epubcfi(");
|
return typeof location === "string" && location.startsWith("epubcfi(");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Book progress 0–100 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. */
|
/** Map nav/TOC href to a spine href epub.js can display. */
|
||||||
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
|
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
|
||||||
const hashIndex = href.indexOf("#");
|
const hashIndex = href.indexOf("#");
|
||||||
|
|||||||
Reference in New Issue
Block a user