feat(reader): stop detecting 卷 in txt splitting (volume lines stay body text, volume-only books fall back to pseudo-sections); replace chapter dropdown with a 目录 sidebar overlay (active row highlighted + centered, click-away/pick to close)

This commit is contained in:
2026-09-07 22:42:10 +08:00
parent 7fb01ce335
commit 7e63c21902
5 changed files with 54 additions and 66 deletions
+2
View File
@@ -17,6 +17,8 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Changed / 变更 ### Changed / 变更
- .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter.
- .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page. - Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 - 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
+4 -5
View File
@@ -38,11 +38,10 @@ body {
} }
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); } .rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); }
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; } .rd-btn:disabled { @apply cursor-not-allowed opacity-40; }
.rd-select { /* 目录侧边栏行 */
@apply rounded-md px-2 py-1 text-sm outline-none; .rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
background: color-mix(in oklab, currentColor 8%, transparent); .rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); }
color: inherit; .rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
}
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); } .rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
/* markdown 正文(仅 .md-body 作用域) */ /* markdown 正文(仅 .md-body 作用域) */
+4 -20
View File
@@ -1,10 +1,9 @@
// txt 章节切分:优先识别「第X章」类标题行;「第X卷/部」是层级不是章节边界, // txt 章节切分:只识别「第X章」类标题行(卷/部不再感知,其行留在正文里);
// 只做分组标签;整本没有章标记时才退而用卷作边界;再没有就按段落边界切 // 整本没有章标记时按段落边界切伪章节,保证任何单块都能在浏览器里流畅渲染。
// 伪章节,保证任何单块都能在浏览器里流畅渲染。纯逻辑,无 DOM,vitest 覆盖。 // 纯逻辑,无 DOM,vitest 覆盖。
export interface TxtChapter { export interface TxtChapter {
title: string; title: string;
start: number; // 正文中的起始字符偏移(含标题行本身) start: number; // 正文中的起始字符偏移(含标题行本身)
vol?: string; // 所属卷标题(仅用于导航分组)
} }
const NUM = "[0-9〇零一二三四五六七八九十百千万两]+"; const NUM = "[0-9〇零一二三四五六七八九十百千万两]+";
@@ -12,7 +11,6 @@ const CH_RE = new RegExp(
`^[ \\t\\u3000]*(?:第\\s*${NUM}\\s*[章节回篇话]|Chapter\\s+${NUM}|序[章言]|楔子|引子|尾声|终章|后记|番外)[^\\n]{0,60}[ \\t]*$`, `^[ \\t\\u3000]*(?:第\\s*${NUM}\\s*[章节回篇话]|Chapter\\s+${NUM}|序[章言]|楔子|引子|尾声|终章|后记|番外)[^\\n]{0,60}[ \\t]*$`,
"gim", "gim",
); );
const VOL_RE = new RegExp(`^[ \\t\\u3000]*第\\s*${NUM}\\s*[卷部][^\\n]{0,60}[ \\t]*$`, "gm");
const MIN_GAP = 200; // 距上一保留标题不足此数视为目录行/正文提及,丢弃 const MIN_GAP = 200; // 距上一保留标题不足此数视为目录行/正文提及,丢弃
const CHUNK = 20_000; // 伪章节目标字数 const CHUNK = 20_000; // 伪章节目标字数
@@ -28,23 +26,9 @@ function markers(text: string, re: RegExp): TxtChapter[] {
} }
export function splitChapters(text: string): TxtChapter[] { export function splitChapters(text: string): TxtChapter[] {
const vols = markers(text, VOL_RE); const chs = markers(text, CH_RE);
let chs = markers(text, CH_RE);
let volMarks = vols;
if (chs.length < 2 && vols.length > 1) {
chs = vols; // 只有卷的书:卷即章节
volMarks = [];
}
if (chs.length > 1) { if (chs.length > 1) {
if (chs[0].start > MIN_GAP) chs.unshift({ title: "开篇", start: 0 }); if (chs[0].start > MIN_GAP) chs.unshift({ title: "开篇", start: 0 });
for (const c of chs) {
let v: TxtChapter | undefined;
for (const m of volMarks) if (m.start < c.start) v = m;
else break;
if (!v) continue;
c.vol = v.title;
if (c.start - v.start < MIN_GAP) c.start = v.start; // 卷题行并入本章开头,不留在上一章尾部
}
return chs; return chs;
} }
const out: TxtChapter[] = []; const out: TxtChapter[] = [];
+38 -34
View File
@@ -131,7 +131,9 @@ function TxtView({ book, initialLocator }: ReaderProps) {
const inited = useRef(false); const inited = useRef(false);
const restoreFrac = useRef<number | null>(null); const restoreFrac = useRef<number | null>(null);
const targetCi = useRef(0); const targetCi = useRef(0);
const activeRow = useRef<HTMLButtonElement>(null);
const [ci, setCi] = useState(0); const [ci, setCi] = useState(0);
const [toc, setToc] = useState(false);
const pr = useReaderPrefs(); const pr = useReaderPrefs();
const { text, err, retry } = useFileText(book.file_url ?? ""); const { text, err, retry } = useFileText(book.file_url ?? "");
@@ -174,6 +176,10 @@ function TxtView({ book, initialLocator }: ReaderProps) {
saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length); saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
} }
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
function onScroll() { function onScroll() {
const el = boxRef.current; const el = boxRef.current;
if (!el || !chapters) return; if (!el || !chapters) return;
@@ -185,44 +191,14 @@ function TxtView({ book, initialLocator }: ReaderProps) {
if (err) return <LoadState text={err} onRetry={retry} />; if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null || !chapters) return <LoadState text="正文加载中…" />; if (text == null || !chapters) return <LoadState text="正文加载中…" />;
const chapterOptions = (() => {
const groups: { vol: string | undefined; items: number[] }[] = [];
chapters.forEach((c, i) => {
const last = groups[groups.length - 1];
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
else last.items.push(i);
});
const opt = (i: number) => (
<option key={i} value={i}>
{i + 1}. {chapters[i].title}
</option>
);
return groups.length === 1 && !groups[0].vol
? chapters.map((_, i) => opt(i))
: groups.map((g, gi) =>
g.vol ? (
<optgroup key={gi} label={g.vol}>
{g.items.map(opt)}
</optgroup>
) : (
g.items.map(opt)
),
);
})();
return ( return (
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}> <div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
<ReaderToolbar pr={pr}> <ReaderToolbar pr={pr}>
{chapters.length > 1 && ( {chapters.length > 1 && (
<> <>
<select <button className="rd-btn" aria-expanded={toc} aria-controls="toc-panel" onClick={() => setToc((v) => !v)}>
className="rd-select max-w-72 truncate" 目录
value={ci} </button>
aria-label="选择章节"
onChange={(e) => goChapter(Number(e.target.value))}
>
{chapterOptions}
</select>
<span className="text-xs opacity-60 tabular-nums"> <span className="text-xs opacity-60 tabular-nums">
{ci + 1}/{chapters.length} {ci + 1}/{chapters.length}
</span> </span>
@@ -247,6 +223,34 @@ function TxtView({ book, initialLocator }: ReaderProps) {
</button> </button>
</div> </div>
)} )}
{/* 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>
</>
)}
</div> </div>
); );
} }
+6 -7
View File
@@ -39,20 +39,19 @@ it("短文无标记 → 单节,chapterText 即全文", () => {
expect(chapterText(t, chs, 0)).toBe(t); expect(chapterText(t, chs, 0)).toBe(t);
}); });
it("卷不是章节边界:只按章切,卷题作分组标签并并入所属章开头", () => { it("卷不感知:只按章切,卷题行留在正文里", () => {
const t = const t =
"第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) + "第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) +
"\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300); "\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300);
const chs = splitChapters(t); const chs = splitChapters(t);
expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]); expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]);
expect(chs.map((c) => c.vol)).toEqual(["第一卷 天云世界", "第一卷 天云世界", "第二卷 仙界"]); expect(chs[0].start).toBe(t.indexOf("第一章"));
expect(chs[2].start).toBe(t.indexOf("第二卷 仙界")); // 卷题行从本章开头渲染,而非上一章结尾 expect(chs[2].start).toBe(t.indexOf("第三章"));
expect(chapterText(t, chs, 2)).toMatch(/^第二卷 仙界/); expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头
}); });
it("只有卷没有章的书:卷即章节", () => { it("只有卷没有章的书:按伪章节切", () => {
const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600); const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600);
const chs = splitChapters(t); const chs = splitChapters(t);
expect(chs.map((c) => c.title)).toEqual(["第一卷 起", "第二卷 承", "第三卷 转"]); expect(chs).toEqual([{ title: "第 1 节", start: 0 }]);
expect(chs.every((c) => c.vol === undefined)).toBe(true);
}); });