feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user