From 7e63c21902807780b91ed2f7c23e1ba064432875 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 22:42:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(reader):=20stop=20detecting=20=E5=8D=B7=20?= =?UTF-8?q?in=20txt=20splitting=20(volume=20lines=20stay=20body=20text,=20?= =?UTF-8?q?volume-only=20books=20fall=20back=20to=20pseudo-sections);=20re?= =?UTF-8?q?place=20chapter=20dropdown=20with=20a=20=E7=9B=AE=E5=BD=95=20si?= =?UTF-8?q?debar=20overlay=20(active=20row=20highlighted=20+=20centered,?= =?UTF-8?q?=20click-away/pick=20to=20close)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 2 + frontend/src/index.css | 9 ++-- frontend/src/lib/chapters.ts | 24 ++-------- frontend/src/readers/TextReader.tsx | 72 +++++++++++++++-------------- frontend/test/chapters.test.ts | 13 +++--- 5 files changed, 54 insertions(+), 66 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 1f9f9f5..5d93b9c 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -17,6 +17,8 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### 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. - 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 diff --git a/frontend/src/index.css b/frontend/src/index.css index b9c1f27..2b8e306 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -38,11 +38,10 @@ body { } .rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); } .rd-btn:disabled { @apply cursor-not-allowed opacity-40; } -.rd-select { - @apply rounded-md px-2 py-1 text-sm outline-none; - background: color-mix(in oklab, currentColor 8%, transparent); - color: inherit; -} +/* 目录侧边栏行 */ +.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; } +.rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); } +.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); } /* markdown 正文(仅 .md-body 作用域) */ diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts index 146a539..a672e0d 100644 --- a/frontend/src/lib/chapters.ts +++ b/frontend/src/lib/chapters.ts @@ -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[] = []; diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 99188b9..5fc9b58 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -131,7 +131,9 @@ function TxtView({ book, initialLocator }: ReaderProps) { const inited = useRef(false); const restoreFrac = useRef(null); const targetCi = useRef(0); + const activeRow = useRef(null); const [ci, setCi] = useState(0); + const [toc, setToc] = useState(false); const pr = useReaderPrefs(); 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); } + useEffect(() => { + if (toc) activeRow.current?.scrollIntoView({ block: "center" }); + }, [toc]); + function onScroll() { const el = boxRef.current; if (!el || !chapters) return; @@ -185,44 +191,14 @@ function TxtView({ book, initialLocator }: ReaderProps) { if (err) return ; if (text == null || !chapters) return ; - 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) => ( - - ); - return groups.length === 1 && !groups[0].vol - ? chapters.map((_, i) => opt(i)) - : groups.map((g, gi) => - g.vol ? ( - - {g.items.map(opt)} - - ) : ( - g.items.map(opt) - ), - ); - })(); - return ( -
+
{chapters.length > 1 && ( <> - + {ci + 1}/{chapters.length} @@ -247,6 +223,34 @@ function TxtView({ book, initialLocator }: ReaderProps) {
)} + {/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */} + {toc && chapters.length > 1 && ( + <> + ); } diff --git a/frontend/test/chapters.test.ts b/frontend/test/chapters.test.ts index d4ef59b..0334de0 100644 --- a/frontend/test/chapters.test.ts +++ b/frontend/test/chapters.test.ts @@ -39,20 +39,19 @@ it("短文无标记 → 单节,chapterText 即全文", () => { expect(chapterText(t, chs, 0)).toBe(t); }); -it("卷不是章节边界:只按章切,卷题作分组标签并并入所属章开头", () => { +it("卷不感知:只按章切,卷题行留在正文里", () => { const t = "第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) + "\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300); const chs = splitChapters(t); expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]); - expect(chs.map((c) => c.vol)).toEqual(["第一卷 天云世界", "第一卷 天云世界", "第二卷 仙界"]); - expect(chs[2].start).toBe(t.indexOf("第二卷 仙界")); // 卷题行从本章开头渲染,而非上一章结尾 - expect(chapterText(t, chs, 2)).toMatch(/^第二卷 仙界/); + expect(chs[0].start).toBe(t.indexOf("第一章")); + expect(chs[2].start).toBe(t.indexOf("第三章")); + expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头 }); -it("只有卷没有章的书:卷即章节", () => { +it("只有卷没有章的书:按伪章节切", () => { const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600); const chs = splitChapters(t); - expect(chs.map((c) => c.title)).toEqual(["第一卷 起", "第二卷 承", "第三卷 转"]); - expect(chs.every((c) => c.vol === undefined)).toBe(true); + expect(chs).toEqual([{ title: "第 1 节", start: 0 }]); });