fix: 阅读全书三处问题(翻页/高亮标记/位置记忆)
1. 翻页 + 跨页选取:默认回到 paginated 翻页模式(epubjs 原生 next/prev,不再失效),新增「连续选取」开关临时切到 scrolled-doc 跨页跨章选文字,切回翻页时 epubjs 自动回到原位置;连续模式下 prev/next 改为按视口高度滚动 2. 高亮不留标记:旧实现用「打开时的快照」画高亮,新建的高亮翻页/滚动回来就消失。改为 ref 实时维护高亮列表,每次内容渲染都重画;保存高亮就地标注,不再 display() 跳转打断阅读 3. 位置不记住:去掉恢复后 currentLocation() 覆盖已存位置的逻辑(scrolled 下恢复未命中会把错误位置写回,导致每次打开都从头开始);位置改由 relocated 事件在正常翻页/滚动时自动保存 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Book as EpubBook, Rendition, Location, NavItem, Contents } from "epubjs";
|
||||
import type Section from "epubjs/types/section";
|
||||
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare, Copy, BookmarkPlus, Highlighter, StickyNote, Trash2, Pencil, X, Download } from "lucide-react";
|
||||
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare, Copy, BookmarkPlus, Highlighter, StickyNote, Trash2, Pencil, X, Download, AlignJustify } from "lucide-react";
|
||||
import { api, type Book, type ReadingNote } from "@/lib/api";
|
||||
|
||||
interface TocEntry { href: string; label: string; depth: number }
|
||||
@@ -43,8 +43,13 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
const [draftNote, setDraftNote] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [turning, setTurning] = useState(false);
|
||||
// 连续选取模式:临时切到 scrolled 以便跨页/跨章选取,切回时 epubjs 自动回到原位置
|
||||
const [continuous, setContinuous] = useState(false);
|
||||
const savedPositionRef = useRef<string | null>(null);
|
||||
const restoringPositionRef = useRef(true);
|
||||
// 当前全部高亮(实时维护,新增/删除都同步),供每次内容渲染时重画
|
||||
const highlightsRef = useRef<ReadingNote[]>([]);
|
||||
const drawnRef = useRef<Set<string>>(new Set());
|
||||
const positionKey = `bookmate.reader.${book.id}`;
|
||||
|
||||
useEffect(() => { closeRef.current = onClose; }, [onClose]);
|
||||
@@ -100,21 +105,29 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
restoringPositionRef.current = true;
|
||||
const rendition = epub.renderTo(host, {
|
||||
width: host.clientWidth, height: host.clientHeight,
|
||||
spread: "none", flow: "scrolled-doc", allowScriptedContent: false,
|
||||
// 默认翻页模式:上一页/下一页、高亮标记、位置记忆都走 epubjs 原生可靠行为;
|
||||
// 跨页选取由「连续选取」开关临时切到 scrolled-doc 完成
|
||||
spread: "none", flow: "paginated", allowScriptedContent: false,
|
||||
});
|
||||
renditionRef.current = rendition;
|
||||
const savedNotes = await api.listReadingNotes(book.id).catch(() => [] as ReadingNote[]);
|
||||
if (disposed) return;
|
||||
setNotes(savedNotes);
|
||||
const drawnHighlights = new Set<string>();
|
||||
// 用 ref 实时维护高亮列表:新增/删除都会同步,每次内容渲染按它重画,
|
||||
// 避免旧实现用「打开时的快照」导致新建的高亮翻页/滚动回来就消失
|
||||
highlightsRef.current = savedNotes.filter((item) => item.kind === "highlight");
|
||||
const highlightStyles = (color: string) => ({ fill: color === "green" ? "#b8d6b0" : color === "blue" ? "#b8d0e5" : "#f3df8a", "fill-opacity": "0.55", "mix-blend-mode": "multiply" });
|
||||
const drawHighlight = (item: ReadingNote) => {
|
||||
if (item.kind !== "highlight" || drawnHighlights.has(item.id)) return;
|
||||
if (item.kind !== "highlight" || drawnRef.current.has(item.id)) return;
|
||||
try {
|
||||
rendition.annotations.highlight(item.cfi, { "data-note-id": item.id }, () => setNotesOpen(true), "epub-saved-highlight", highlightStyles(item.color));
|
||||
drawnHighlights.add(item.id);
|
||||
drawnRef.current.add(item.id);
|
||||
} catch { /* A stale CFI must not prevent the book from opening. */ }
|
||||
};
|
||||
const drawAllHighlights = () => {
|
||||
drawnRef.current = new Set();
|
||||
for (const item of highlightsRef.current) drawHighlight(item);
|
||||
};
|
||||
rendition.themes.default({
|
||||
body: { color: "#243b33", background: "#fffdf8", "line-height": "1.9 !important", "font-family": 'Georgia, "Songti SC", "SimSun", serif' },
|
||||
"body, body *": { "user-select": "text !important", "-webkit-user-select": "text !important" },
|
||||
@@ -135,7 +148,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
});
|
||||
rendition.on("rendered", () => {
|
||||
if (disposed) return;
|
||||
for (const item of savedNotes) drawHighlight(item);
|
||||
drawAllHighlights();
|
||||
});
|
||||
rendition.on("selected", (cfi: string, contents: Contents) => {
|
||||
if (disposed) return;
|
||||
@@ -157,12 +170,10 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
await rendition.display();
|
||||
}
|
||||
if (disposed) return;
|
||||
// Ignore the initial relocation event while restoring, then persist the
|
||||
// actual displayed position so later navigation can update it normally.
|
||||
// 恢复完成后才放行 relocated 的自动保存。不要在这里用 currentLocation() 覆盖
|
||||
// 已存位置:scrolled 模式下若恢复未命中,会把错误位置存回去,导致每次打开都从头开始
|
||||
restoringPositionRef.current = false;
|
||||
const displayed = await rendition.currentLocation();
|
||||
if (displayed?.cfi) save(positionKey, displayed.cfi);
|
||||
for (const item of savedNotes) drawHighlight(item);
|
||||
drawAllHighlights();
|
||||
setError("");
|
||||
setLoading(false);
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
@@ -185,18 +196,50 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
};
|
||||
}, [book.id, attempt, positionKey]);
|
||||
|
||||
// 连续选取模式下的「翻页」:按一个视口高度滚动(scrolled 没有原生页码概念)
|
||||
const scrollByViewport = (dir: 1 | -1) => {
|
||||
const root = viewerRef.current;
|
||||
if (!root) return;
|
||||
const scroller = root.scrollHeight > root.clientHeight + 1
|
||||
? root
|
||||
: root.querySelector<HTMLElement>(".epub-container") || root;
|
||||
scroller.scrollBy({ top: dir * scroller.clientHeight, behavior: "smooth" });
|
||||
};
|
||||
const navigate = async (target: "prev" | "next" | string) => {
|
||||
const rendition = renditionRef.current;
|
||||
if (!rendition || turning || loading) return;
|
||||
setTurning(true);
|
||||
setError("");
|
||||
try {
|
||||
if (target === "prev") await rendition.prev();
|
||||
else if (target === "next") await rendition.next();
|
||||
else { await rendition.display(target); setTocOpen(false); }
|
||||
if (target === "prev") {
|
||||
if (continuous) scrollByViewport(-1); else await rendition.prev();
|
||||
} else if (target === "next") {
|
||||
if (continuous) scrollByViewport(1); else await rendition.next();
|
||||
} else { await rendition.display(target); setTocOpen(false); }
|
||||
} catch { setError("这一页未能显示,请重试或从目录选择其他章节。"); }
|
||||
finally { setTurning(false); }
|
||||
};
|
||||
// 连续选取模式:翻页 ↔ 连续滚动切换。epubjs 的 flow() 切回翻页时会自动 display
|
||||
// 当前阅读位置,不会丢位置。
|
||||
const toggleContinuous = async () => {
|
||||
const rendition = renditionRef.current;
|
||||
if (!rendition || turning || loading) return;
|
||||
setTurning(true);
|
||||
setError("");
|
||||
try {
|
||||
if (continuous) {
|
||||
await rendition.flow("paginated");
|
||||
setContinuous(false);
|
||||
setNotice("已切回翻页模式");
|
||||
} else {
|
||||
await rendition.flow("scrolled-doc");
|
||||
setContinuous(true);
|
||||
setNotice("连续选取模式:可跨页跨章选中文字");
|
||||
}
|
||||
} catch { setError("切换阅读模式失败,请重试。"); }
|
||||
finally { setTurning(false); }
|
||||
window.setTimeout(() => setNotice(""), 2200);
|
||||
};
|
||||
const changeFont = (delta: number) => {
|
||||
const next = Math.max(14, Math.min(28, fontSize + delta));
|
||||
setFontSize(next);
|
||||
@@ -228,9 +271,12 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
const created = await api.createReadingNote(book.id, { kind, excerpt: selectedText, cfi: selectedCfi, chapter_title: currentChapter?.label || null, color: "yellow", note: draftNote.trim() || null });
|
||||
setNotes((items) => [created, ...items]);
|
||||
if (kind === "highlight") {
|
||||
const styles = { fill: "#f3df8a", "fill-opacity": "0.55", "mix-blend-mode": "multiply" };
|
||||
await renditionRef.current?.display(created.cfi);
|
||||
try { renditionRef.current?.annotations.highlight(created.cfi, { "data-note-id": created.id }, () => setNotesOpen(true), "epub-saved-highlight", styles); } catch { /* The saved note remains available from the notes panel. */ }
|
||||
// 就地高亮,不要 display() 跳转:选中处本就在眼前,跳转反而打断阅读,
|
||||
// 且跨页选取产生的是 range cfi,display() 无法定位
|
||||
highlightsRef.current = [created, ...highlightsRef.current.filter((n) => n.id !== created.id)];
|
||||
try {
|
||||
renditionRef.current?.annotations.highlight(created.cfi, { "data-note-id": created.id }, () => setNotesOpen(true), "epub-saved-highlight", { fill: "#f3df8a", "fill-opacity": "0.55", "mix-blend-mode": "multiply" });
|
||||
} catch { /* The saved note remains available from the notes panel. */ }
|
||||
}
|
||||
setDraftNote(""); setSelectedText(""); setSelectedCfi(""); setNotesOpen(true); setNotice(kind === "highlight" ? "已添加高亮" : "已保存摘录");
|
||||
window.setTimeout(() => setNotice(""), 2200);
|
||||
@@ -243,7 +289,10 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
const removeNote = async (item: ReadingNote) => {
|
||||
try {
|
||||
await api.deleteReadingNote(book.id, item.id);
|
||||
if (item.kind === "highlight") renditionRef.current?.annotations.remove(item.cfi, "epub-saved-highlight");
|
||||
if (item.kind === "highlight") {
|
||||
highlightsRef.current = highlightsRef.current.filter((n) => n.id !== item.id);
|
||||
renditionRef.current?.annotations.remove(item.cfi, "epub-saved-highlight");
|
||||
}
|
||||
setNotes((items) => items.filter((n) => n.id !== item.id));
|
||||
} catch (err) { setNotice(err instanceof Error ? err.message : "删除失败"); }
|
||||
};
|
||||
@@ -286,6 +335,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
<div className="epub-book-title"><strong>{book.title || book.filename}</strong><small>{book.author || "完整 EPUB 阅读"}</small></div>
|
||||
<button aria-label="切换摘录与笔记" aria-expanded={notesOpen} onClick={() => setNotesOpen(!notesOpen)}><StickyNote size={18} /><span>摘录 {notes.length || ""}</span></button>
|
||||
<button aria-label="切换目录" aria-expanded={tocOpen} onClick={() => setTocOpen(!tocOpen)}><List size={19} /><span>目录</span></button>
|
||||
<button aria-label="切换连续选取模式" aria-pressed={continuous} onClick={() => void toggleContinuous()} disabled={loading || turning}><AlignJustify size={18} /><span>{continuous ? "连续选取中" : "连续选取"}</span></button>
|
||||
<div className="epub-font-controls"><button aria-label="缩小字号" disabled={fontSize <= 14 || loading} onClick={() => changeFont(-2)}><Minus size={16} /></button><span>{fontSize}</span><button aria-label="增大字号" disabled={fontSize >= 28 || loading} onClick={() => changeFont(2)}><Plus size={16} /></button></div>
|
||||
</header>
|
||||
<div className="epub-body">
|
||||
@@ -303,7 +353,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
{notice && <div className="epub-notice" role="status">{notice}</div>}
|
||||
<footer className="epub-footer">
|
||||
<button onClick={() => void navigate("prev")} disabled={loading || turning || !location || location.atStart}><ChevronLeft size={18} /><span>上一页</span></button>
|
||||
<div aria-live="polite"><strong>{currentChapter?.label || "阅读原文"}</strong><small>{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置</small></div>
|
||||
<div aria-live="polite"><strong>{currentChapter?.label || "阅读原文"}</strong>{continuous ? <small>连续选取模式 · 可跨页跨章选中文字保存</small> : <small>{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置</small>}</div>
|
||||
<button onClick={() => void navigate("next")} disabled={loading || turning || !location || location.atEnd}><span>下一页</span><ChevronRight size={18} /></button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user