From d989668650209944b079c0828c0fded5462009b3 Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 01:06:39 +0800 Subject: [PATCH] feat(bookmarks): shared useBookmarks panel + wired into all four readers --- frontend/src/components/Bookmarks.tsx | 154 ++++++++++++++++++++++++++ frontend/src/readers/CbzReader.tsx | 8 +- frontend/src/readers/EpubReader.tsx | 19 +++- frontend/src/readers/PdfReader.tsx | 17 ++- frontend/src/readers/TextReader.tsx | 22 +++- 5 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/Bookmarks.tsx diff --git a/frontend/src/components/Bookmarks.tsx b/frontend/src/components/Bookmarks.tsx new file mode 100644 index 0000000..3b74b82 --- /dev/null +++ b/frontend/src/components/Bookmarks.tsx @@ -0,0 +1,154 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { api } from "../api/client"; +import type { Book } from "../api/types"; +import { toast } from "./Toaster"; + +type Locator = Record; + +export function useBookmarks(opts: { + book: Book; + capture: () => { locator: Locator; percent: number }; + seek: (locator: Locator) => void; +}): { btn: ReactNode; panel: ReactNode } { + const { book, capture, seek } = opts; + const qc = useQueryClient(); + const [open, setOpen] = useState(false); + const [note, setNote] = useState(""); + const [editId, setEditId] = useState(null); + const [editVal, setEditVal] = useState(""); + const q = useQuery({ queryKey: ["bookmarks", book.id], queryFn: () => api.listBookmarks(book.id) }); + const items = q.data ?? []; + const invalidate = () => qc.invalidateQueries({ queryKey: ["bookmarks", book.id] }); + const fail = (e: unknown) => toast("err", e instanceof Error ? e.message : "书签操作失败"); + + const add = useMutation({ + mutationFn: () => { + const { locator, percent } = capture(); + return api.createBookmark(book.id, locator, percent, note.trim()); + }, + onSuccess: () => { + setNote(""); + invalidate(); + toast("ok", "已加书签"); + }, + onError: fail, + }); + const patch = useMutation({ + mutationFn: (v: { id: number; note: string }) => api.patchBookmark(v.id, v.note), + onSuccess: () => { + setEditId(null); + invalidate(); + }, + onError: fail, + }); + const del = useMutation({ + mutationFn: (id: number) => api.deleteBookmark(id), + onSuccess: invalidate, + onError: fail, + }); + + const btn = ( + + ); + + const panel = open ? ( + <> + ); } diff --git a/frontend/src/readers/EpubReader.tsx b/frontend/src/readers/EpubReader.tsx index 178ed0d..a4bb235 100644 --- a/frontend/src/readers/EpubReader.tsx +++ b/frontend/src/readers/EpubReader.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { apiRaw } from "../api/client"; +import { useBookmarks } from "../components/Bookmarks"; import { btn } from "../components/ui"; +import { toast } from "../components/Toaster"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; // 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性 @@ -26,10 +28,19 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> { return m.default as (data: Uint8Array) => EpubBookLike; } -export default function EpubReader({ book, initialLocator }: ReaderProps) { +export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) { const hostRef = useRef(null); const rendRef = useRef(null); - const saver = useProgressSaver(book.id); + const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); + const bm = useBookmarks({ + book, + capture: saver.capture, + seek: (l) => { + const cfi = String(l.cfi ?? ""); + if (!cfi) return; + Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败")); + }, + }); const [err, setErr] = useState(""); const [nonce, setNonce] = useState(0); const [ready, setReady] = useState(false); @@ -90,7 +101,7 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) { ); return ( -
+
{!ready && !err &&
EPUB 加载中…
} @@ -102,7 +113,9 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) { + {bm.btn}
+ {bm.panel}
); } diff --git a/frontend/src/readers/PdfReader.tsx b/frontend/src/readers/PdfReader.tsx index 62bc376..32bfcaa 100644 --- a/frontend/src/readers/PdfReader.tsx +++ b/frontend/src/readers/PdfReader.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import * as pdfjs from "pdfjs-dist"; import { apiRaw } from "../api/client"; +import { useBookmarks } from "../components/Bookmarks"; import { btn } from "../components/ui"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; @@ -12,14 +13,22 @@ pdfjs.GlobalWorkerOptions.workerPort = new Worker( type RenderTask = { cancel(): void; promise: Promise }; -export default function PdfReader({ book, initialLocator }: ReaderProps) { +export default function PdfReader({ book, initialLocator, initialPercent }: ReaderProps) { const wrapRef = useRef(null); const canvasRef = useRef(null); const docRef = useRef(null); const docTaskRef = useRef<{ destroy(): Promise } | null>(null); const taskRef = useRef(null); const restored = useRef(false); - const saver = useProgressSaver(book.id); + const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); + const bm = useBookmarks({ + book, + capture: saver.capture, + seek: (l) => { + const p = Number(l.page); + if (Number.isInteger(p) && p >= 0 && p < num) setPage(p); + }, + }); const [num, setNum] = useState(0); const [page, setPage] = useState(0); // 0 基 const [err, setErr] = useState(""); @@ -130,7 +139,7 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) { tabIndex={0} onKeyDown={onKey} aria-label="PDF 阅读器,可用左右方向键翻页" - className="grid h-full grid-rows-[1fr_auto] focus:outline-none" + className="relative grid h-full grid-rows-[1fr_auto] focus:outline-none" >
@@ -151,7 +160,9 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) { + {bm.btn}
+ {bm.panel}
); } diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 4e382f1..72dbb98 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -2,6 +2,7 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { apiRaw } from "../api/client"; +import { useBookmarks } from "../components/Bookmarks"; import { btn } from "../components/ui"; import { chapterText, seekChapter, splitChapters } from "../lib/chapters"; import { decodeText } from "../lib/encoding"; @@ -130,9 +131,10 @@ export default function TextReader(props: ReaderProps) { return props.book.format === "md" ? : ; } -function MdView({ book, initialLocator, chrome }: ReaderProps) { +function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) { const boxRef = useRef(null); - const saver = useProgressSaver(book.id); + const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); + const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) }); const restored = useRef(false); const pr = useReaderPrefs(); const [frac, setFrac] = useState(0); @@ -168,7 +170,7 @@ function MdView({ book, initialLocator, chrome }: ReaderProps) { if (err) return ; if (text == null) return ; return ( -
+
- {chrome.on && } + {chrome.on && {bm.btn}} + {bm.panel}
); } // 整本塞一个
 会冻死浏览器:按章渲染,一章一个滚动块。
-function TxtView({ book, initialLocator, chrome }: ReaderProps) {
+function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
   const boxRef = useRef(null);
-  const saver = useProgressSaver(book.id);
+  const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
   const inited = useRef(false);
   const restoreFrac = useRef(null);
   const targetCi = useRef(0);
@@ -199,6 +202,11 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
   const [toc, setToc] = useState(false);
   const pr = useReaderPrefs();
   const { text, err, retry } = useFileText(book.file_url ?? "");
+  const bm = useBookmarks({
+    book,
+    capture: saver.capture,
+    seek: (l) => goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0),
+  });
 
   const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
 
@@ -279,6 +287,7 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
       
{chrome.on && ( + {bm.btn} {chapters.length > 1 && ( <>
); }