From 065fde5b56a7fbf83196041670a2b0e3c440716c Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 23:03:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(reader):=20immersive=20chrome=20+=20WeChat?= =?UTF-8?q?-Read/Mihon-style=20bottom=20sheets=20=E2=80=94=20tap-center=20?= =?UTF-8?q?toggles=20header/toolbar,=20progress=20&=20page=20sliders=20see?= =?UTF-8?q?k=20anywhere,=20WYSIWYG=20theme=20swatches,=20A=C2=B1=20font=20?= =?UTF-8?q?size;=20CBZ=20gains=20=E8=BF=9E=E8=AF=BB/=E6=95=B4=E9=A1=B5/?= =?UTF-8?q?=E9=80=82=E9=AB=98=20modes,=20edge-tap=20&=20arrow-key=20paging?= =?UTF-8?q?,=20persisted=20mode=20(seekChapter=20helper=20+=20test)?= 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);