From 379e7a3b0ba1b566fd93ca3fe8dc16f1191256e0 Mon Sep 17 00:00:00 2001 From: gavindiaz Date: Mon, 26 Oct 2026 17:24:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=98=85=E8=AF=BB=E5=85=A8=E4=B9=A6?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E9=97=AE=E9=A2=98=EF=BC=88=E7=BF=BB=E9=A1=B5?= =?UTF-8?q?/=E9=AB=98=E4=BA=AE=E6=A0=87=E8=AE=B0/=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 翻页 + 跨页选取:默认回到 paginated 翻页模式(epubjs 原生 next/prev,不再失效),新增「连续选取」开关临时切到 scrolled-doc 跨页跨章选文字,切回翻页时 epubjs 自动回到原位置;连续模式下 prev/next 改为按视口高度滚动 2. 高亮不留标记:旧实现用「打开时的快照」画高亮,新建的高亮翻页/滚动回来就消失。改为 ref 实时维护高亮列表,每次内容渲染都重画;保存高亮就地标注,不再 display() 跳转打断阅读 3. 位置不记住:去掉恢复后 currentLocation() 覆盖已存位置的逻辑(scrolled 下恢复未命中会把错误位置写回,导致每次打开都从头开始);位置改由 relocated 事件在正常翻页/滚动时自动保存 Co-Authored-By: Claude Code --- frontend/src/components/EpubReader.tsx | 88 ++++++++++++++++++++------ 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/EpubReader.tsx b/frontend/src/components/EpubReader.tsx index f2e3022..21d1620 100644 --- a/frontend/src/components/EpubReader.tsx +++ b/frontend/src/components/EpubReader.tsx @@ -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(null); const restoringPositionRef = useRef(true); + // 当前全部高亮(实时维护,新增/删除都同步),供每次内容渲染时重画 + const highlightsRef = useRef([]); + const drawnRef = useRef>(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(); + // 用 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(".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 }: {
{book.title || book.filename}{book.author || "完整 EPUB 阅读"}
+
{fontSize}
@@ -303,7 +353,7 @@ export default function EpubReader({ book, onClose, onAsk }: { {notice &&
{notice}
}