From 7fb01ce335a9e226b41db68a78ed144ecfb31d32 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 22:37:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20warm=20library=20palette=20(zin?= =?UTF-8?q?c+emerald=20->=20stone+amber)=20with=20hover/focus=20feedback;?= =?UTF-8?q?=20text=20reader=20gains=20paper/sepia/night=20themes=20+=20per?= =?UTF-8?q?sisted=20font-size=20(lib/readerPrefs.ts),=20serif=20justified?= =?UTF-8?q?=20measure;=20PDF=20drops=20emoji=20icons=20for=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5/=E6=9C=AB=E9=A1=B5,=20CBZ=20pill=20page=20counter,=20?= =?UTF-8?q?EPUB=20centered=20spread,=20blur=20reader=20header=20with=20for?= =?UTF-8?q?mat=20badge,=20aria=20labels=20on=20login/search/chapter=20sele?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 12 +- frontend/index.html | 2 +- frontend/src/auth/AuthContext.tsx | 2 +- frontend/src/components/Cover.tsx | 6 +- frontend/src/components/ErrorBoundary.tsx | 4 +- frontend/src/components/Toaster.tsx | 2 +- frontend/src/components/TopBar.tsx | 18 ++- frontend/src/components/ui.ts | 8 +- frontend/src/index.css | 45 +++++-- frontend/src/lib/readerPrefs.ts | 36 ++++++ frontend/src/pages/Login.tsx | 4 +- frontend/src/pages/Reader.tsx | 21 +-- frontend/src/pages/Shelf.tsx | 29 +++-- frontend/src/pages/admin/Libraries.tsx | 8 +- frontend/src/pages/admin/Users.tsx | 8 +- frontend/src/readers/CbzReader.tsx | 15 ++- frontend/src/readers/EpubReader.tsx | 6 +- frontend/src/readers/PdfReader.tsx | 29 +++-- frontend/src/readers/TextReader.tsx | 148 +++++++++++++++------- frontend/vite.config.ts | 4 +- 20 files changed, 281 insertions(+), 126 deletions(-) create mode 100644 frontend/src/lib/readerPrefs.ts diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index f64fe15..1f9f9f5 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -8,7 +8,17 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ## [Unreleased] -### Fixed / 修复 +### Added / 新增 + +- Text (.txt/.md) reader upgrades: paper / sepia / night reading themes and adjustable font size, with a compact prefs bar; choices persist across books (localStorage); body text is now serif with a readable measure and justified lines. +- 文本阅读器(.txt/.md)升级:新增 纸/米/夜 三种阅读主题与字号调节,工具条常驻;偏好跨书持久化(localStorage);正文改为衬线字体、收窄行宽并两端对齐,更利于长时间阅读。 +- PDF reader: emoji skip icons replaced with 首页/末页 text buttons, framed page canvas, and an accessible label announcing arrow-key paging. +- PDF 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。 + +### Changed / 变更 + +- 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 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 - .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM. - GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。 diff --git a/frontend/index.html b/frontend/index.html index a158cc3..7c3f092 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + BookLib — 个人书库 diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 1b45f6f..78e8bcd 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -77,7 +77,7 @@ export function RequireAuth({ children }: { children: ReactNode }) { const { user, ready } = useAuth(); const loc = useLocation(); if (!getToken()) return ; - if (!ready || !user) return
加载中…
; + if (!ready || !user) return
加载中…
; return <>{children}; } diff --git a/frontend/src/components/Cover.tsx b/frontend/src/components/Cover.tsx index 8406bf8..aa5a76b 100644 --- a/frontend/src/components/Cover.tsx +++ b/frontend/src/components/Cover.tsx @@ -29,12 +29,12 @@ export function Cover({ book, className }: { book: Book; className?: string }) { const { src, failed, retry } = useAuthedImage(book.cover_url); if (failed) return ( -
+
); - if (!src) return
; - return ; + if (!src) return
; + return ; } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 61ef37f..5793f32 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -13,8 +13,8 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err

页面出错了

-

{String(this.state.err)}

-
diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index affe5f5..a569d6d 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -31,7 +31,7 @@ export function Toaster() {
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 8428b3f..bd5687f 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -6,23 +6,29 @@ import { btn } from "./ui"; export function TopBar({ right }: { right?: ReactNode }) { const { user, isAdmin, logout } = useAuth(); return ( -
- - BookLib +
+ + BookLib {isAdmin && ( - + 用户 )} {isAdmin && ( - + 库管理 )}
{right} - + {user?.username} {isAdmin ? "(admin)" : ""} diff --git a/frontend/src/components/ui.ts b/frontend/src/components/ui.ts index 5c56510..e9a37aa 100644 --- a/frontend/src/components/ui.ts +++ b/frontend/src/components/ui.ts @@ -1,7 +1,7 @@ export const btn = - "rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"; + "rounded-lg bg-stone-800 px-3 py-1.5 text-sm transition-colors hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-stone-600 disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = - "rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"; + "rounded-lg bg-amber-600 px-3 py-1.5 text-sm text-stone-950 transition-colors hover:bg-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"; export const input = - "rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500"; -export const card = "rounded-xl border border-zinc-800 bg-zinc-900"; + "rounded-lg border border-stone-700 bg-stone-900 px-3 py-1.5 text-sm outline-none transition-colors placeholder:text-stone-500 focus:border-amber-500/70"; +export const card = "rounded-xl border border-stone-800 bg-stone-900"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 5b28d2c..b9c1f27 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,15 +6,44 @@ body, height: 100%; } body { - @apply bg-zinc-950 text-zinc-100 antialiased; + @apply bg-stone-950 text-stone-100 antialiased; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { - @apply rounded bg-zinc-700; + @apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70; } +::-webkit-scrollbar-thumb:hover { + @apply bg-stone-600; +} + +/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */ +[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; } +[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; } +[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; } +.rd-surface { + background: var(--rd-bg); + color: var(--rd-fg); + font-family: ui-serif, "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", SimSun, Georgia, serif; +} +.rd-surface ::selection { + background: color-mix(in oklab, var(--rd-link) 30%, transparent); +} +/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */ +.rd-btn { + @apply rounded-md px-2.5 py-1 text-sm transition-colors; + background: color-mix(in oklab, currentColor 8%, transparent); +} +.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-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); } /* markdown 正文(仅 .md-body 作用域) */ .md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; } @@ -23,12 +52,12 @@ body { .md-body p { @apply my-3; } .md-body ul { @apply my-3 list-disc space-y-1 pl-6; } .md-body ol { @apply my-3 list-decimal space-y-1 pl-6; } -.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; } -.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; } -.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; } +.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; } +.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); } +.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); } .md-body pre code { @apply bg-transparent p-0; } -.md-body a { @apply text-emerald-400 underline; } +.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; } .md-body img { @apply my-3 max-w-full rounded; } -.md-body hr { @apply my-6 border-zinc-800; } +.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); } .md-body table { @apply my-3 w-full border-collapse text-sm; } -.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; } +.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); } diff --git a/frontend/src/lib/readerPrefs.ts b/frontend/src/lib/readerPrefs.ts new file mode 100644 index 0000000..bc29246 --- /dev/null +++ b/frontend/src/lib/readerPrefs.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +export const RD_THEMES = ["paper", "sepia", "night"] as const; +export type RdTheme = (typeof RD_THEMES)[number]; +export const RD_THEME_LABEL: Record = { paper: "纸", sepia: "米", night: "夜" }; +export const FONT_SIZES = [16, 18, 20, 22, 25, 28]; + +const KEY = "reader-prefs"; + +export interface ReaderPrefs { + theme: RdTheme; + sizeIdx: number; +} + +export function useReaderPrefs() { + const [prefs, setPrefs] = useState(() => { + try { + const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs; + if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length) + return p; + } catch { + /* 首次使用或存了坏值 */ + } + return { theme: "sepia", sizeIdx: 2 }; + }); + useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]); + + return { + prefs, + fontSize: FONT_SIZES[prefs.sizeIdx], + cycleTheme: () => + setPrefs((p) => ({ ...p, theme: RD_THEMES[(RD_THEMES.indexOf(p.theme) + 1) % RD_THEMES.length] })), + bump: (d: number) => + setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })), + }; +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 5402644..ed47ef0 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -30,9 +30,9 @@ export default function Login() {

BookLib 登录

setU(e.target.value)} - placeholder="用户名" autoFocus autoComplete="username" /> + aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" /> setP(e.target.value)} - placeholder="密码" autoComplete="current-password" /> + aria-label="密码" placeholder="密码" autoComplete="current-password" /> diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index dfd537a..6b3ac47 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -57,16 +57,21 @@ export default function Reader() { return (
-
- +
+ ← 书架 {book?.title ?? "…"} - - {book?.library ? book.library + " · " : ""} - {book?.format} - {book ? ` · ${Math.round(book.percent * 100)}%` : ""} - + {book && ( + + {book.library && {book.library}} + {book.format} + {Math.round(book.percent * 100)}% + + )} {isAdmin && book && ( @@ -38,13 +43,13 @@ function BookCard({ )} {book.percent > 0 && ( -
-
+
+
)} {isAdmin && ( - 白名单:cbz/zip/pdf/epub/txt/md + 白名单:cbz/zip/pdf/epub/txt/md
))} {(libsQ.data ?? []).length === 0 && !libsQ.isPending && ( -

还没有库,用上方表单创建。

+

还没有库,用上方表单创建。

)}
diff --git a/frontend/src/pages/admin/Users.tsx b/frontend/src/pages/admin/Users.tsx index 5153cd8..ae48636 100644 --- a/frontend/src/pages/admin/Users.tsx +++ b/frontend/src/pages/admin/Users.tsx @@ -55,7 +55,7 @@ export default function AdminUsers() {
- + @@ -66,11 +66,11 @@ export default function AdminUsers() { {(usersQ.data ?? []).map((x) => ( - - + + - +
ID 用户名
{x.id}
{x.id} {x.username} {x.role}{x.created_at.slice(0, 19).replace("T", " ")}{x.created_at.slice(0, 19).replace("T", " ")} diff --git a/frontend/src/readers/PdfReader.tsx b/frontend/src/readers/PdfReader.tsx index 238f065..62bc376 100644 --- a/frontend/src/readers/PdfReader.tsx +++ b/frontend/src/readers/PdfReader.tsx @@ -123,28 +123,33 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) { ); - if (num === 0) return
PDF 加载中…
; + if (num === 0) return
PDF 加载中…
; return ( -
-
- +
+
+
-
- - + {page + 1}/{num} -
diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 85506ba..99188b9 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -1,12 +1,47 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { apiRaw } from "../api/client"; -import { btn, input } from "../components/ui"; +import { btn } from "../components/ui"; import { chapterText, splitChapters } from "../lib/chapters"; import { decodeText } from "../lib/encoding"; +import { FONT_SIZES, RD_THEME_LABEL, useReaderPrefs } from "../lib/readerPrefs"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; +type Prefs = ReturnType; + +function PrefsBar({ pr }: { pr: Prefs }) { + return ( +
+ + + +
+ ); +} + +function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) { + return ( +
+ {children} +
+ +
+
+ ); +} + function useFileText(url: string) { const [text, setText] = useState(null); const [err, setErr] = useState(""); @@ -31,7 +66,7 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
{onRetry &&

{text}

} - {!onRetry &&

{text}

} + {!onRetry &&

{text}

} {onRetry && ( -
)} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fca6d67..c413fe4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,8 +14,8 @@ export default defineConfig({ name: "BookLib 个人书库", short_name: "BookLib", description: "个人书库/漫画库在线阅读", - theme_color: "#09090b", - background_color: "#09090b", + theme_color: "#0c0a09", + background_color: "#0c0a09", display: "standalone", icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }], }, From 7e63c21902807780b91ed2f7c23e1ba064432875 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 22:42:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(reader):=20stop=20detecting=20?= =?UTF-8?q?=E5=8D=B7=20in=20txt=20splitting=20(volume=20lines=20stay=20bod?= =?UTF-8?q?y=20text,=20volume-only=20books=20fall=20back=20to=20pseudo-sec?= =?UTF-8?q?tions);=20replace=20chapter=20dropdown=20with=20a=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20sidebar=20overlay=20(active=20row=20highlighted=20+?= =?UTF-8?q?=20centered,=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 }]); }); From 065fde5b56a7fbf83196041670a2b0e3c440716c Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 23:03:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(reader):=20immersive=20chrome=20+=20We?= =?UTF-8?q?Chat-Read/Mihon-style=20bottom=20sheets=20=E2=80=94=20tap-cente?= =?UTF-8?q?r=20toggles=20header/toolbar,=20progress=20&=20page=20sliders?= =?UTF-8?q?=20seek=20anywhere,=20WYSIWYG=20theme=20swatches,=20A=C2=B1=20f?= =?UTF-8?q?ont=20size;=20CBZ=20gains=20=E8=BF=9E=E8=AF=BB/=E6=95=B4?= =?UTF-8?q?=E9=A1=B5/=E9=80=82=E9=AB=98=20modes,=20edge-tap=20&=20arrow-ke?= =?UTF-8?q?y=20paging,=20persisted=20mode=20(seekChapter=20helper=20+=20te?= =?UTF-8?q?st)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 6 +- frontend/src/index.css | 15 ++ frontend/src/lib/chapters.ts | 7 + frontend/src/lib/readerPrefs.ts | 3 +- frontend/src/lib/useProgress.ts | 7 + frontend/src/pages/Reader.tsx | 16 +- frontend/src/readers/CbzReader.tsx | 132 ++++++++++++++-- frontend/src/readers/TextReader.tsx | 235 +++++++++++++++++++--------- frontend/test/chapters.test.ts | 11 +- 9 files changed, 330 insertions(+), 102 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 5d93b9c..28a7dde 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,8 +10,10 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 -- Text (.txt/.md) reader upgrades: paper / sepia / night reading themes and adjustable font size, with a compact prefs bar; choices persist across books (localStorage); body text is now serif with a readable measure and justified lines. -- 文本阅读器(.txt/.md)升级:新增 纸/米/夜 三种阅读主题与字号调节,工具条常驻;偏好跨书持久化(localStorage);正文改为衬线字体、收窄行宽并两端对齐,更利于长时间阅读。 +- Immersive reading for text & comics (微信读书/Mihon-style): tap the middle of the page to show/hide the reader header and control bar; controls now live in a slide-up bottom sheet — a progress slider jumps to any position (text) or page (CBZ), theme swatches (纸/米/夜) are WYSIWYG color dots, plus A−/A+ font size; body text is serif with a readable justified measure; all prefs persist (localStorage). +- 文本与漫画进入沉浸阅读:点正文中央唤出/隐藏顶栏与工具条;控件收进底部滑出抽屉——进度/页码滑条直接跳到全书任意位置或任意页,主题改为所见即所得的色卡(纸/米/夜),保留 A−/A+ 字号;txt/md 正文为衬线、限宽两端对齐;偏好本地持久化。 +- CBZ adds reading modes — 连读 (continuous scroll) / 整页 (snap to page top) / 适高 (one page per screen, full width) — cycle button in the bar, choice remembered; tapping the left/right edges turns a page/screen and ←/→/PageUp/PageDown work too. +- CBZ 新增三种阅读模式——连读 / 整页吸附 / 适高(一页一屏满宽),工具条一键循环切换并记忆;点按画面左右缘翻页(屏),并支持 ←/→/PageUp/PageDown 键。 - PDF reader: emoji skip icons replaced with 首页/末页 text buttons, framed page canvas, and an accessible label announcing arrow-key paging. - PDF 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。 diff --git a/frontend/src/index.css b/frontend/src/index.css index 2b8e306..277608f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -44,6 +44,21 @@ body { .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-range { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full; background: color-mix(in oklab, currentColor 18%, transparent); } +.rd-range::-webkit-slider-thumb { + @apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10; background: var(--rd-link, #f59e0b); +} +.rd-range::-moz-range-thumb { @apply h-4 w-4 rounded-full border-0; background: var(--rd-link, #f59e0b); } + +/* 沉浸态底部工具抽屉的滑入动效 */ +@keyframes rd-rise { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } } +.rd-sheet { animation: rd-rise 0.2s ease-out; } +@media (prefers-reduced-motion: reduce) { + .rd-sheet { animation: none; } + .rd-rise-anim { transition: none !important; } +} + /* markdown 正文(仅 .md-body 作用域) */ .md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; } .md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; } diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts index a672e0d..15922a1 100644 --- a/frontend/src/lib/chapters.ts +++ b/frontend/src/lib/chapters.ts @@ -47,3 +47,10 @@ export function splitChapters(text: string): TxtChapter[] { export function chapterText(text: string, chapters: TxtChapter[], i: number): string { return text.slice(chapters[i].start, chapters[i + 1]?.start ?? text.length); } + +/** 全书进度分数(0..1)→ 章序号 + 章内分数。进度滑条用。 */ +export function seekChapter(n: number, frac: number): { idx: number; intra: number } { + const p = Math.min(0.9999, Math.max(0, frac)) * n; + const idx = Math.min(n - 1, Math.floor(p)); + return { idx, intra: p - idx }; +} diff --git a/frontend/src/lib/readerPrefs.ts b/frontend/src/lib/readerPrefs.ts index bc29246..23278b1 100644 --- a/frontend/src/lib/readerPrefs.ts +++ b/frontend/src/lib/readerPrefs.ts @@ -28,8 +28,7 @@ export function useReaderPrefs() { return { prefs, fontSize: FONT_SIZES[prefs.sizeIdx], - cycleTheme: () => - setPrefs((p) => ({ ...p, theme: RD_THEMES[(RD_THEMES.indexOf(p.theme) + 1) % RD_THEMES.length] })), + setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })), bump: (d: number) => setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })), }; diff --git a/frontend/src/lib/useProgress.ts b/frontend/src/lib/useProgress.ts index 2821b79..eb96af4 100644 --- a/frontend/src/lib/useProgress.ts +++ b/frontend/src/lib/useProgress.ts @@ -4,9 +4,16 @@ import { api } from "../api/client"; import type { Book } from "../api/types"; import { createProgressSaver, type ProgressSaver } from "./progress"; +export interface Chrome { + on: boolean; + toggle(): void; + show(): void; +} + export interface ReaderProps { book: Book; initialLocator?: Record; + chrome: Chrome; } export function useProgressSaver(bookId: number): ProgressSaver { diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 6b3ac47..8509725 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from "react"; +import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { api } from "../api/client"; import type { Format } from "../api/types"; @@ -22,6 +22,11 @@ export default function Reader() { const qc = useQueryClient(); const nav = useNavigate(); const { isAdmin } = useAuth(); + const [chromeOn, setChromeOn] = useState(true); + + const toggle = useCallback(() => setChromeOn((v) => !v), []); + const show = useCallback(() => setChromeOn(true), []); + const chrome = useMemo(() => ({ on: chromeOn, toggle, show }), [chromeOn, toggle, show]); const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); @@ -52,12 +57,17 @@ export default function Reader() { const R = READERS[book.format]; if (!R) return ; const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path); - return ; + return ; })(); return (
-
+
void }) { +type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏) +const MODES: PageMode[] = ["scroll", "snap", "fit"]; +const MODE_LABEL: Record = { scroll: "连读", snap: "整页", fit: "适高" }; + +function useStoredMode() { + const [mode, setMode] = useState(() => { + const v = localStorage.getItem("cbz-mode"); + return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll"; + }); + useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]); + return [mode, setMode] as const; +} + +function PageImg({ + url, + width, + fitH, + onLoaded, +}: { + url: string; + width: number; + fitH: boolean; + onLoaded: (h: number) => void; +}) { const [src, setSrc] = useState(""); const [failed, setFailed] = useState(false); const [nonce, setNonce] = useState(0); @@ -34,7 +57,11 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
); if (!src) - return
; + return ( +
+ ); + if (fitH) + return ; return ( api.pageCount(book.pages_url ?? ""), @@ -64,8 +91,13 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) { const [top, setTop] = useState(0); const [, bump] = useState(0); const restored = useRef(false); + const [mode, setMode] = useStoredMode(); + const fitH = mode === "fit"; - const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]); + const ph = useMemo( + () => new PageHeights(count, fitH ? vh : width * EST_RATIO), + [count, width, vh, fitH], + ); // 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准) useEffect(() => { @@ -100,6 +132,26 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) { } } + const cur = ph.pageAt(top + vh / 2); + + function jump(i: number) { + const el = boxRef.current; + if (!el) return; + el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: "smooth" }); + chrome.show(); + } + + // 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区) + function onZoneClick(e: MouseEvent) { + if ((e.target as HTMLElement).closest("button,input,nav,a")) return; + if (window.getSelection()?.toString()) return; + const r = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width; + if (x < 0.28) jump(cur - 1); + else if (x > 0.72) jump(cur + 1); + else chrome.toggle(); + } + // pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态 if (countQ.isPending && !!book.pages_url) return
页索引加载中…
; @@ -112,20 +164,40 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) { const fmt: string = book.page_url_fmt; const [a, b] = ph.range(top, vh, 2); - const cur = ph.pageAt(top + vh / 2); return ( -
-
-
+
{ + if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1); + else return; + e.preventDefault(); + }} + > +
+
{Array.from({ length: b - a }, (_, k) => { const i = a + k; return ( -
+
{ + if (fitH) return; // 适高模式定高一页一屏,无需量高 const el = boxRef.current; const topOfI = ph.offset(i); const d = ph.set(i, h); @@ -138,12 +210,40 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) { })}
-
- - {cur + 1}/{count} - - {Math.round(((cur + 1) / count) * 100)}% -
+ {chrome.on ? ( +
+
+ jump(Number(e.target.value))} + className="rd-range" + aria-label="页码跳转" + /> + + {cur + 1}/{count} · {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% + +
+
+ +
+
+ ) : ( +
+ + {cur + 1}/{count} + + {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% +
+ )}
); } diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 5fc9b58..4e382f1 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -1,46 +1,22 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { apiRaw } from "../api/client"; import { btn } from "../components/ui"; -import { chapterText, splitChapters } from "../lib/chapters"; +import { chapterText, seekChapter, splitChapters } from "../lib/chapters"; import { decodeText } from "../lib/encoding"; -import { FONT_SIZES, RD_THEME_LABEL, useReaderPrefs } from "../lib/readerPrefs"; +import { + FONT_SIZES, + RD_THEMES, + RD_THEME_LABEL, + useReaderPrefs, + type RdTheme, +} from "../lib/readerPrefs"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; type Prefs = ReturnType; -function PrefsBar({ pr }: { pr: Prefs }) { - return ( -
- - - -
- ); -} - -function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) { - return ( -
- {children} -
- -
-
- ); -} +const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" }; function useFileText(url: string) { const [text, setText] = useState(null); @@ -77,15 +53,89 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) { ); } +/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */ +function ReaderSheet({ + pr, + frac, + onSeek, + children, +}: { + pr: Prefs; + frac: number; + onSeek: (f: number) => void; + children?: ReactNode; +}) { + return ( +
+
+ onSeek(Number(e.target.value) / 1000)} + className="rd-range" + aria-label="阅读进度" + /> + + {Math.round(frac * 100)}% + +
+
+ {children} +
+
+ {RD_THEMES.map((t) => ( +
+ + +
+
+
+ ); +} + +/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */ +function bodyTogglesChrome(e: MouseEvent, toggle: () => void) { + if ((e.target as HTMLElement).closest("a,button,input,nav")) return; + if (window.getSelection()?.toString()) return; + toggle(); +} + export default function TextReader(props: ReaderProps) { return props.book.format === "md" ? : ; } -function MdView({ book, initialLocator }: ReaderProps) { +function MdView({ book, initialLocator, chrome }: ReaderProps) { const boxRef = useRef(null); const saver = useProgressSaver(book.id); const restored = useRef(false); const pr = useReaderPrefs(); + const [frac, setFrac] = useState(0); const { text, err, retry } = useFileText(book.file_url ?? ""); const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]); @@ -104,28 +154,40 @@ function MdView({ book, initialLocator }: ReaderProps) { const el = boxRef.current; if (!el) return; const max = el.scrollHeight - el.clientHeight; - const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0; - saver.report({ scrollFraction: frac }, frac); + const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0; + setFrac(f); + saver.report({ scrollFraction: f }, f); + } + + function seek(f: number) { + const el = boxRef.current; + if (!el) return; + el.scrollTop = f * (el.scrollHeight - el.clientHeight); } if (err) return ; if (text == null) return ; return (
- -
+
bodyTogglesChrome(e, chrome.toggle)} + className="min-h-0 flex-1 overflow-y-auto px-6 py-6" + >
+ {chrome.on && }
); } // 整本塞一个
 会冻死浏览器:按章渲染,一章一个滚动块。
-function TxtView({ book, initialLocator }: ReaderProps) {
+function TxtView({ book, initialLocator, chrome }: ReaderProps) {
   const boxRef = useRef(null);
   const saver = useProgressSaver(book.id);
   const inited = useRef(false);
@@ -133,6 +195,7 @@ function TxtView({ book, initialLocator }: ReaderProps) {
   const targetCi = useRef(0);
   const activeRow = useRef(null);
   const [ci, setCi] = useState(0);
+  const [frac, setFrac] = useState(0);
   const [toc, setToc] = useState(false);
   const pr = useReaderPrefs();
   const { text, err, retry } = useFileText(book.file_url ?? "");
@@ -145,18 +208,15 @@ function TxtView({ book, initialLocator }: ReaderProps) {
     const f = Number(initialLocator?.scrollFraction);
     const hasF = Number.isFinite(f) && f > 0 && f <= 1;
     const rawCh = Number(initialLocator?.ch);
-    let idx = 0;
     if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
-      idx = Math.floor(rawCh);
+      targetCi.current = Math.floor(rawCh);
+      setCi(Math.floor(rawCh));
       if (hasF) restoreFrac.current = f;
     } else if (hasF) {
-      const p = f * chapters.length; // 旧版全局分数 → 章节近似定位
-      idx = Math.min(chapters.length - 1, Math.floor(p));
-      restoreFrac.current = p - idx;
-    }
-    if (idx > 0 || restoreFrac.current != null) {
+      const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
       targetCi.current = idx;
       setCi(idx);
+      if (intra > 0) restoreFrac.current = intra;
     }
   }, [chapters, initialLocator]);
 
@@ -169,43 +229,47 @@ function TxtView({ book, initialLocator }: ReaderProps) {
     } else el.scrollTop = 0;
   }, [ci, chapters]);
 
-  function goChapter(i: number) {
-    if (!chapters) return;
-    restoreFrac.current = null;
-    setCi(i);
-    saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
-  }
-
   useEffect(() => {
     if (toc) activeRow.current?.scrollIntoView({ block: "center" });
   }, [toc]);
 
+  function goChapter(i: number, intra = 0) {
+    if (!chapters) return;
+    restoreFrac.current = intra > 0 ? intra : null;
+    targetCi.current = i;
+    setCi(i);
+    setFrac(intra);
+    saver.report({ ch: i, scrollFraction: intra }, (i + intra) / chapters.length);
+  }
+
   function onScroll() {
     const el = boxRef.current;
     if (!el || !chapters) return;
     const max = el.scrollHeight - el.clientHeight;
-    const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
-    saver.report({ ch: ci, scrollFraction: frac }, (ci + frac) / chapters.length);
+    const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
+    setFrac(f);
+    saver.report({ ch: ci, scrollFraction: f }, (ci + f) / chapters.length);
+  }
+
+  function seek(f: number) {
+    if (!chapters) return;
+    const { idx, intra } = seekChapter(chapters.length, f);
+    goChapter(idx, intra);
   }
 
   if (err) return ;
   if (text == null || !chapters) return ;
 
+  const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
+
   return (
     
- - {chapters.length > 1 && ( - <> - - - {ci + 1}/{chapters.length} - - - )} - -
+
bodyTogglesChrome(e, chrome.toggle)} + className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6" + >
       
- {chapters.length > 1 && ( -
- - -
+ {chrome.on && ( + + {chapters.length > 1 && ( + <> + + + {ci + 1}/{chapters.length} + + + + + )} + )} {/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */} {toc && chapters.length > 1 && ( diff --git a/frontend/test/chapters.test.ts b/frontend/test/chapters.test.ts index 0334de0..56cf15c 100644 --- a/frontend/test/chapters.test.ts +++ b/frontend/test/chapters.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { chapterText, splitChapters } from "../src/lib/chapters"; +import { chapterText, seekChapter, splitChapters } from "../src/lib/chapters"; const pad = (n: number) => "字".repeat(n); @@ -50,6 +50,15 @@ it("卷不感知:只按章切,卷题行留在正文里", () => { expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头 }); +it("seekChapter:全局进度分数 → (章序号, 章内分数),端点与越界安全", () => { + expect(seekChapter(69, 0)).toEqual({ idx: 0, intra: 0 }); + expect(seekChapter(69, 0.5)).toEqual({ idx: 34, intra: 0.5 }); // 69*0.5=34.5 + expect(seekChapter(69, 1).idx).toBe(68); // 顶到 0.9999 防越界 + expect(seekChapter(69, 1).intra).toBeGreaterThan(0.99); + expect(seekChapter(69, -0.2)).toEqual({ idx: 0, intra: 0 }); + expect(seekChapter(1, 0.3)).toEqual({ idx: 0, intra: 0.3 }); +}); + it("只有卷没有章的书:按伪章节切", () => { const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600); const chs = splitChapters(t); From c6dfb92c7693cd4701a234ef125751417a99ac19 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 23:13:04 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(cbz):=20smooth=20scrollbar/slider=20dra?= =?UTF-8?q?gging=20=E2=80=94=20rAF-coalesce=20scroll=20handler,=20idle-deb?= =?UTF-8?q?ounced=20prefetch,=20frame-batched=20height=20corrections,=20Pa?= =?UTF-8?q?geHeights.setEst=20converges=20unseen=20pages=20to=20the=20firs?= =?UTF-8?q?t=20measured=20page=20size,=20page=20slider=20seeks=20instantly?= =?UTF-8?q?=20while=20scrubbing=20(no=20per-notch=20smooth-animation=20res?= =?UTF-8?q?tart);=20restore=20lost=20###=20Fixed=20header=20in=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 4 ++ frontend/src/lib/virt.ts | 15 ++++++ frontend/src/readers/CbzReader.tsx | 86 +++++++++++++++++++++++------- frontend/test/virt.test.ts | 8 +++ 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 28a7dde..a676fca 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -24,6 +24,10 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab - 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 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 +### Fixed / 修复 + +- Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch. +- 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。 - .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM. - GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。 - Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter. diff --git a/frontend/src/lib/virt.ts b/frontend/src/lib/virt.ts index 6b85f13..03a75e5 100644 --- a/frontend/src/lib/virt.ts +++ b/frontend/src/lib/virt.ts @@ -18,6 +18,21 @@ export class PageHeights { for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est); } + get estHeight(): number { + return this.est; + } + + /** 重估默认页高(同开本漫画量出真实比例后,滚动条几何就不再逐页变了)。 + * 返回起始偏移 < y 的未量高页数——调用方可按 n×Δest 自行补偿 scrollTop。 */ + setEst(e: number, y: number): number { + if (!Number.isFinite(e) || e <= 0 || e === this.est) return 0; + let n = 0; + for (let i = 0; i < this.n && this.cum[i] < y; i++) if (this.actual[i] === undefined) n++; + this.est = e; + this.rebuild(0); + return n; + } + /** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */ set(i: number, h: number): number { if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0; diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 15e6973..2a8b558 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -89,13 +89,20 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) const [width, setWidth] = useState(480); const [vh, setVh] = useState(600); const [top, setTop] = useState(0); + const [scrub, setScrub] = useState(null); // 页滑条拖拽中的临时值 const [, bump] = useState(0); const restored = useRef(false); + const scrollRaf = useRef(0); + const idle = useRef>(undefined); + const curRef = useRef(0); + const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop + const shiftRaf = useRef(0); + const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定) const [mode, setMode] = useStoredMode(); const fitH = mode === "fit"; const ph = useMemo( - () => new PageHeights(count, fitH ? vh : width * EST_RATIO), + () => new PageHeights(count, fitH ? vh : width * ratio.current), [count, width, vh, fitH], ); @@ -119,25 +126,53 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p); }, [count, initialLocator, ph]); + // 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴 function onScroll() { - const el = boxRef.current; - if (!el) return; - const t = el.scrollTop; - setTop(t); - if (count) { - const cur = ph.pageAt(t + vh / 2); - saver.report({ page: cur }, (cur + 1) / count); - for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++) - fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页 - } + if (scrollRaf.current) return; + scrollRaf.current = requestAnimationFrame(() => { + scrollRaf.current = 0; + const el = boxRef.current; + if (!el) return; + const t = el.scrollTop; + setTop(t); + if (count) { + curRef.current = ph.pageAt(t + vh / 2); + saver.report({ page: curRef.current }, (curRef.current + 1) / count); + } + clearTimeout(idle.current); + idle.current = setTimeout(() => { + for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++) + fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); + }, 250); + }); + } + + useEffect( + () => () => { + cancelAnimationFrame(scrollRaf.current); + cancelAnimationFrame(shiftRaf.current); + clearTimeout(idle.current); + }, + [], + ); + + function applyShift(d: number) { + shift.current += d; + if (shiftRaf.current) return; + shiftRaf.current = requestAnimationFrame(() => { + shiftRaf.current = 0; + const el = boxRef.current; + if (el && shift.current) el.scrollTop += shift.current; + shift.current = 0; + }); } const cur = ph.pageAt(top + vh / 2); - function jump(i: number) { + function jump(i: number, smooth = true) { const el = boxRef.current; if (!el) return; - el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: "smooth" }); + el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" }); chrome.show(); } @@ -181,7 +216,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) ref={boxRef} onScroll={onScroll} onClick={onZoneClick} - className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-mandatory" : "")} + className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-proximity" : "")} >
{Array.from({ length: b - a }, (_, k) => { @@ -199,9 +234,17 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) onLoaded={(h) => { if (fitH) return; // 适高模式定高一页一屏,无需量高 const el = boxRef.current; + const topY = el ? el.scrollTop : 0; + const oldEst = ph.estHeight; const topOfI = ph.offset(i); const d = ph.set(i, h); - if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动 + if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动 + // 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架 + if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) { + const n = ph.setEst(h, topY); + if (el && n) applyShift(n * (h - oldEst)); + ratio.current = h / width; + } bump((x) => x + 1); }} /> @@ -217,13 +260,20 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) type="range" min={0} max={Math.max(0, count - 1)} - value={cur} - onChange={(e) => jump(Number(e.target.value))} + value={scrub ?? cur} + onChange={(e) => { + const v = Number(e.target.value); + setScrub(v); + jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿 + }} + onPointerUp={() => setScrub(null)} + onPointerCancel={() => setScrub(null)} + onBlur={() => setScrub(null)} className="rd-range" aria-label="页码跳转" /> - {cur + 1}/{count} · {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% + {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
diff --git a/frontend/test/virt.test.ts b/frontend/test/virt.test.ts index aafceeb..59214bf 100644 --- a/frontend/test/virt.test.ts +++ b/frontend/test/virt.test.ts @@ -23,6 +23,14 @@ it("set 修正高度并平移后续 offset;返回增量;幂等", () => { expect(ph.set(2, 150)).toBe(0); }); +it("setEst:重估默认页高,返回视口上方「未量高」页数,已量高页保留", () => { + const ph = new PageHeights(5, 100); // cum 顶部: 0,100,200,300,400 + ph.set(0, 160); // 页0 实测 160 → cum 顶部: 0,160,260,360,460 + expect(ph.setEst(140, 260)).toBe(1); // 顶部<260 且未量高:页1(160)。页0 已量高排除,页2(260)不<260 + expect(ph.total()).toBe(160 + 4 * 140); // 页0 实测保留,其余按新估高 + expect(ph.setEst(140, 9999)).toBe(0); // 同值幂等:est 不变直接 0 +}); + it("n=0 时一切归空", () => { const ph = new PageHeights(0, 100); expect(ph.total()).toBe(0);