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
+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>
);
}