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
+4 -20
View File
@@ -1,10 +1,9 @@
// txt 章节切分:优先识别「第X章」类标题行;「第X卷/部」是层级不是章节边界,
// 只做分组标签;整本没有章标记时才退而用卷作边界;再没有就按段落边界切
// 伪章节,保证任何单块都能在浏览器里流畅渲染。纯逻辑,无 DOM,vitest 覆盖。
// txt 章节切分:只识别「第X章」类标题行(卷/部不再感知,其行留在正文里);
// 整本没有章标记时按段落边界切伪章节,保证任何单块都能在浏览器里流畅渲染。
// 纯逻辑,无 DOM,vitest 覆盖。
export interface TxtChapter {
title: string;
start: number; // 正文中的起始字符偏移(含标题行本身)
vol?: string; // 所属卷标题(仅用于导航分组)
}
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]*$`,
"gim",
);
const VOL_RE = new RegExp(`^[ \\t\\u3000]*第\\s*${NUM}\\s*[卷部][^\\n]{0,60}[ \\t]*$`, "gm");
const MIN_GAP = 200; // 距上一保留标题不足此数视为目录行/正文提及,丢弃
const CHUNK = 20_000; // 伪章节目标字数
@@ -28,23 +26,9 @@ function markers(text: string, re: RegExp): TxtChapter[] {
}
export function splitChapters(text: string): TxtChapter[] {
const vols = markers(text, VOL_RE);
let chs = markers(text, CH_RE);
let volMarks = vols;
if (chs.length < 2 && vols.length > 1) {
chs = vols; // 只有卷的书:卷即章节
volMarks = [];
}
const chs = markers(text, CH_RE);
if (chs.length > 1) {
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;
}
const out: TxtChapter[] = [];