feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs

This commit is contained in:
2026-09-08 21:08:42 +08:00
parent 4f4c7e5434
commit 48161ea1ff
7 changed files with 340 additions and 211 deletions
+86 -106
View File
@@ -1,19 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
export function useBookmarks(opts: {
/** 书签面板内容(供 ReaderNav 的「书签」选项卡承载);跳转后由调用方负责关抽屉. */
export function BookmarksTab({
book,
capture,
seek,
}: {
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<number | null>(null);
const [editVal, setEditVal] = useState("");
@@ -48,107 +51,84 @@ export function useBookmarks(opts: {
onError: fail,
});
const btn = (
<button
className="rd-btn"
aria-expanded={open}
aria-controls="bm-panel"
onClick={() => setOpen((v) => !v)}
>
书签{items.length ? ` ${items.length}` : ""}
</button>
);
const panel = open ? (
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={() => setOpen(false)} />
<nav
id="bm-panel"
aria-label="书签"
className="rd-divider absolute inset-y-0 right-0 z-40 flex w-72 flex-col border-l bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
placeholder="备注(可空)"
maxLength={500}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
加
</button>
</div>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn shrink-0"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
✓
</button>
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
✕
</button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
title={b.note || undefined}
onClick={() => {
seek(b.locator);
setOpen(false);
}}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
setEditVal(b.note);
}}
>
✎
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
✕
</button>
</div>
)}
</li>
))}
</ul>
</nav>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
placeholder="备注(可空)"
maxLength={500}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
加
</button>
</div>
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn shrink-0"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
✓
</button>
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
✕
</button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
title={b.note || undefined}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
setEditVal(b.note);
}}
>
✎
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
✕
</button>
</div>
)}
</li>
))}
</ul>
</>
) : null;
return { btn, panel };
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
export type NavTab = "toc" | "bm";
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
export function ReaderNav({
tab,
onTab,
onClose,
toc,
bookmarks,
}: {
tab: NavTab;
onTab?: (t: NavTab) => void;
onClose: () => void;
toc?: ReactNode;
bookmarks: ReactNode;
}) {
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
<nav
id="reader-nav"
aria-label="导航"
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<div
role={toc != null ? "tablist" : undefined}
aria-label="导航选项卡"
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
>
{toc != null ? (
<>
<button
role="tab"
aria-selected={tab === "toc"}
className={"rd-btn" + (tab === "toc" ? " rd-btn-on" : "")}
onClick={() => onTab?.("toc")}
>
目录
</button>
<button
role="tab"
aria-selected={tab === "bm"}
className={"rd-btn" + (tab === "bm" ? " rd-btn-on" : "")}
onClick={() => onTab?.("bm")}
>
书签
</button>
</>
) : (
<span className="rd-btn rd-btn-on">书签</span>
)}
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
✕
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
</nav>
</>
);
}
+55 -34
View File
@@ -2,7 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
@@ -79,10 +80,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
const [toc, setToc] = useState(false);
const [nav, setNav] = useState<null | NavTab>(null);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
@@ -190,8 +190,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
function scrollToPage(i: number, smooth: boolean) {
const el = boxRef.current;
@@ -358,10 +358,22 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
预读 {prefetch} 章
</button>
)}
{bm.btn}
<button
className="rd-btn"
aria-expanded={nav === "bm"}
aria-controls="reader-nav"
onClick={() => setNav(nav === "bm" ? null : "bm")}
>
书签
</button>
{chapters && (
<>
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
<button
className="rd-btn"
aria-expanded={nav === "toc"}
aria-controls="reader-nav"
onClick={() => setNav(nav === "toc" ? null : "toc")}
>
目录
</button>
<span className="text-xs tabular-nums opacity-60">
@@ -389,34 +401,43 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
{toc && chapters && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="cbz-toc"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goPage(c.start);
setToc(false);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
</nav>
</>
{nav && (
<ReaderNav
tab={chapters ? nav : "bm"}
onTab={setNav}
onClose={() => setNav(null)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
goPage(Number(l.page) || 0);
setNav(null);
}}
/>
}
toc={
chapters && (
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goPage(c.start);
setNav(null);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
)
}
/>
)}
{bm.panel}
</div>
);
}
+24 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { toast } from "../components/Toaster";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
@@ -32,15 +33,7 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
const hostRef = useRef<HTMLDivElement>(null);
const rendRef = useRef<EpubRendition | null>(null);
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 [nav, setNav] = useState(false);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
const [ready, setReady] = useState(false);
@@ -113,9 +106,28 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
<button className={btn} onClick={() => rendRef.current?.next()}>
下一页
</button>
{bm.btn}
<button className="rd-btn" aria-expanded={nav} aria-controls="reader-nav" onClick={() => setNav((v) => !v)}>
书签
</button>
</div>
{bm.panel}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
const cfi = String(l.cfi ?? "");
setNav(false);
if (!cfi) return;
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
}}
/>
}
/>
)}
</div>
);
}
+23 -11
View File
@@ -1,7 +1,8 @@
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 { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
@@ -21,14 +22,7 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
const taskRef = useRef<RenderTask | null>(null);
const restored = useRef(false);
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 [nav, setNav] = useState(false);
const [num, setNum] = useState(0);
const [page, setPage] = useState(0); // 0 基
const [err, setErr] = useState("");
@@ -160,9 +154,27 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
末页
</button>
{bm.btn}
<button className="rd-btn" aria-expanded={nav} aria-controls="reader-nav" onClick={() => setNav((v) => !v)}>
书签
</button>
</div>
{bm.panel}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
const p = Number(l.page);
setNav(false);
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
}}
/>
}
/>
)}
</div>
);
}
+82 -44
View File
@@ -2,7 +2,8 @@ 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 { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { btn } from "../components/ui";
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
@@ -134,7 +135,7 @@ export default function TextReader(props: ReaderProps) {
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) });
const [nav, setNav] = useState(false);
const restored = useRef(false);
const pr = useReaderPrefs();
const [frac, setFrac] = useState(0);
@@ -183,8 +184,34 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
dangerouslySetInnerHTML={{ __html: html ?? "" }}
/>
</div>
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek}>{bm.btn}</ReaderSheet>}
{bm.panel}
{chrome.on && (
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
<button
className="rd-btn"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
书签
</button>
</ReaderSheet>
)}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
seek(Number(l.scrollFraction) || 0);
setNav(false);
}}
/>
}
/>
)}
</div>
);
}
@@ -199,14 +226,9 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const activeRow = useRef<HTMLButtonElement>(null);
const [ci, setCi] = useState(0);
const [frac, setFrac] = useState(0);
const [toc, setToc] = useState(false);
const [nav, setNav] = useState<null | NavTab>(null);
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]);
@@ -238,8 +260,8 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
}, [ci, chapters]);
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
function goChapter(i: number, intra = 0) {
if (!chapters) return;
@@ -287,14 +309,21 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
{bm.btn}
<button
className="rd-btn"
aria-expanded={nav === "bm"}
aria-controls="reader-nav"
onClick={() => setNav(nav === "bm" ? null : "bm")}
>
书签
</button>
{chapters.length > 1 && (
<>
<button
className="rd-btn"
aria-expanded={toc}
aria-controls="toc-panel"
onClick={() => setToc((v) => !v)}
aria-expanded={nav === "toc"}
aria-controls="reader-nav"
onClick={() => setNav(nav === "toc" ? null : "toc")}
>
目录
</button>
@@ -311,35 +340,44 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
)}
</ReaderSheet>
)}
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
{toc && chapters.length > 1 && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="toc-panel"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goChapter(i);
setToc(false);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
</nav>
</>
{nav && (
<ReaderNav
tab={chapters.length > 1 ? nav : "bm"}
onTab={setNav}
onClose={() => setNav(null)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0);
setNav(null);
}}
/>
}
toc={
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
chapters.length > 1 && (
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goChapter(i);
setNav(null);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
)
}
/>
)}
{bm.panel}
</div>
);
}