Archived
Feature/uv implementation #26
@@ -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,
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(");
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
|
||||
const hashIndex = href.indexOf("#");
|
||||
|
||||
Reference in New Issue
Block a user