diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 5eac058..50e439f 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme. +- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。 + - CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support. - CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。 diff --git a/frontend/src/components/reader-settings.test.tsx b/frontend/src/components/reader-settings.test.tsx index a208e6b..c39b906 100644 --- a/frontend/src/components/reader-settings.test.tsx +++ b/frontend/src/components/reader-settings.test.tsx @@ -3,7 +3,6 @@ import { expect } from "vitest"; import { beforeEach, describe, it } from "vitest"; import { ReaderSettings } from "@/components/reader-settings"; import { ThemeProvider } from "@/components/theme"; -import { DEFAULT_PREFS } from "@/lib/readerPrefs"; beforeEach(() => { localStorage.clear(); @@ -48,11 +47,16 @@ describe("ReaderSettings", () => { }); it("showFont=false 时无 A−/A+", () => { - renderSettings({ showFont: false }); + renderSettings({ showFont: false, onFont: () => {} }); expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument(); }); + it("未传 onFont 时 A−/A+ 隐藏(showFont 也无字号钮)", () => { + renderSettings({ showFont: true }); + expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument(); + }); + it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => { renderSettings({ extra:
额外面板
}); const toggle = screen.getByRole("button", { name: "更多阅读设置" }); @@ -63,10 +67,10 @@ describe("ReaderSettings", () => { expect(screen.getByTestId("extra")).toBeInTheDocument(); }); - it("showFont 时 A+ 点击 → text.sizeIdx 增加", () => { - renderSettings({ showFont: true }); + it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => { + let got = 0; + renderSettings({ showFont: true, onFont: (d: number) => (got = d) }); fireEvent.click(screen.getByRole("button", { name: "加大字号" })); - const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}"); - expect(stored.text.sizeIdx).toBe(DEFAULT_PREFS.text.sizeIdx + 1); + expect(got).toBe(1); }); }); diff --git a/frontend/src/components/reader-settings.tsx b/frontend/src/components/reader-settings.tsx index a80044b..7f1777d 100644 --- a/frontend/src/components/reader-settings.tsx +++ b/frontend/src/components/reader-settings.tsx @@ -14,6 +14,8 @@ export interface ReaderSettingsProps { left?: ReactNode; /** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */ showFont?: boolean; + /** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */ + onFont?: (d: number) => void; /** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */ extra?: ReactNode; } @@ -22,7 +24,7 @@ export interface ReaderSettingsProps { const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" }; /** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */ -export function ReaderSettings({ slider, position, left, showFont = false, extra }: ReaderSettingsProps) { +export function ReaderSettings({ slider, position, left, showFont = false, onFont, extra }: ReaderSettingsProps) { const pr = useReaderPrefs(); const globalDark = useGlobalDark(); const rd = resolveRdTheme(pr.prefs.themeMode, globalDark); @@ -68,14 +70,14 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra
- {showFont && ( + {showFont && onFont && ( <> @@ -84,7 +86,7 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra size="icon-xs" aria-label="加大字号" disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1} - onClick={() => pr.bumpText(1)} + onClick={() => onFont(1)} > A+ @@ -114,3 +116,13 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra
); } + +/** 设置行通用骨架:label + 右侧控件组。 */ +export function SettingRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 22be12d..7001d78 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -1,10 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { api, formatPageUrl } from "../api/client"; import { BookmarksTab } from "../components/Bookmarks"; import { ReaderNav, type NavTab } from "../components/reader-nav"; -import { ReaderSettings } from "../components/reader-settings"; +import { ReaderSettings, SettingRow } from "../components/reader-settings"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { fetchObjectUrl } from "../lib/authImage"; @@ -563,16 +563,6 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome ); } -/** 设置行通用骨架:label + 右侧控件组。 */ -function SettingRow({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - type Chrome = { on: boolean; toggle: () => void; show: () => void }; /** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */ diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 5dd4199..882650f 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -1,27 +1,20 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { apiRaw } from "../api/client"; import { BookmarksTab } from "../components/Bookmarks"; import { ReaderNav, type NavTab } from "../components/reader-nav"; -import { btn } from "../components/ui"; +import { ReaderSettings, SettingRow } from "../components/reader-settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useGlobalDark } from "../components/theme"; import { chapterText, seekChapter, splitChapters } from "../lib/chapters"; import { decodeText } from "../lib/encoding"; -import { - FONT_SIZES, - RD_THEMES, - RD_THEME_LABEL, - resolveRdTheme, - useReaderPrefs, - type RdTheme, -} from "../lib/readerPrefs"; +import { LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs"; +import { searchChapters, splitHighlight, type SearchHit } from "../lib/search"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; -type Prefs = ReturnType; - -const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" }; - function useFileText(url: string) { const [text, setText] = useState(null); const [err, setErr] = useState(""); @@ -38,9 +31,22 @@ function useFileText(url: string) { useEffect(() => { let dead = false; apiRaw(url) - .then((r) => r.arrayBuffer()) - .then((b) => !dead && setText(decodeText(b))) - .catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败")); + .then((r) => { + if (dead) return; + if (!r.ok) { + r.text().then((t) => { + if (!dead) setErr(t || `HTTP ${r.status}`); + }); + return; + } + r.arrayBuffer().then((buf) => { + if (dead) return; + setText(decodeText(buf)); + }); + }) + .catch((e) => { + if (!dead) setErr(e instanceof Error ? e.message : "加载失败"); + }); return () => { dead = true; }; @@ -50,89 +56,19 @@ function useFileText(url: string) { function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) { return ( -
+
- {onRetry &&

{text}

} - {!onRetry &&

{text}

} +

{text}

{onRetry && ( - + )}
); } -/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。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; @@ -150,6 +86,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) { const [nav, setNav] = useState(false); const restored = useRef(false); const pr = useReaderPrefs(); + const globalDark = useGlobalDark(); + const rd = resolveRdTheme(pr.prefs.themeMode, globalDark); const [frac, setFrac] = useState(0); const { text, err, retry } = useFileText(book.file_url ?? ""); @@ -183,7 +121,7 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) { if (err) return ; if (text == null) return ; return ( -
+
{chrome.on && ( - - - + seek(v / 1000), + ariaLabel: "阅读进度", + }} + position={`${Math.round(frac * 100)}%`} + left={ + + } + showFont + onFont={pr.bumpText} + extra={ + <> + + + + } + /> )} {nav && ( ; epub?: boolean }) { + const cur = epub ? pr.prefs.epub.lineIdx : pr.prefs.text.lineIdx; + const patch = (i: number) => + pr.update((p) => (epub ? { ...p, epub: { ...p.epub, lineIdx: i } } : { ...p, text: { ...p.text, lineIdx: i } })); + return ( + + {LINE_HEIGHTS.map((lh, i) => ( + + ))} + + ); +} + +/** 边距三档按钮组(text/epub 各自消费 margin/marginIdx)。 */ +function MarginRow({ pr, epub = false }: { pr: ReturnType; epub?: boolean }) { + const cur = epub ? pr.prefs.epub.marginIdx : MARGINS.indexOf(pr.prefs.text.margin); + const patch = (i: number) => + pr.update((p) => + epub ? { ...p, epub: { ...p.epub, marginIdx: i } } : { ...p, text: { ...p.text, margin: MARGINS[i] } }, + ); + return ( + + {MARGINS.map((m, i) => ( + + ))} + + ); +} + // 整本塞一个
 会冻死浏览器:按章渲染,一章一个滚动块。
 function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
   const boxRef = useRef(null);
@@ -239,7 +230,11 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
   const [ci, setCi] = useState(0);
   const [frac, setFrac] = useState(0);
   const [nav, setNav] = useState(null);
+  const [query, setQuery] = useState("");
+  const [hits, setHits] = useState(null);
   const pr = useReaderPrefs();
+  const globalDark = useGlobalDark();
+  const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
   const { text, err, retry } = useFileText(book.file_url ?? "");
 
   const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -276,6 +271,15 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
     if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
   }, [nav]);
 
+  // 全书搜索:250ms 防抖,空 query 在同一 timer 内清空结果(避免 effect 内同步 setState)
+  useEffect(() => {
+    if (!chapters || !text) return;
+    const t = setTimeout(() => {
+      setHits(query.trim() ? searchChapters(text, chapters, query) : null);
+    }, 250);
+    return () => clearTimeout(t);
+  }, [query, chapters, text]);
+
   function goChapter(i: number, intra = 0) {
     if (!chapters) return;
     restoreFrac.current = intra > 0 ? intra : null;
@@ -306,7 +310,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
   const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
 
   return (
-    
+
           {chapterText(text, chapters, ci)}
         
{chrome.on && ( - - - {chapters.length > 1 && ( + seek(v / 1000), + ariaLabel: "阅读进度", + }} + position={`${Math.round(total * 100)}%`} + left={ <> - - {ci + 1}/{chapters.length} - - - + 导航 + + {chapters.length > 1 && ( + <> + + {ci + 1}/{chapters.length} + + + + + )} - )} - + } + showFont + onFont={pr.bumpText} + extra={ + <> + + + + } + /> )} {nav && ( } + search={ + { + const len = chapterText(text, chapters, h.ch).length; + goChapter(h.ch, Math.min(1, Math.max(0, h.pos / Math.max(1, len)))); + setNav(null); + }} + /> + } toc={ /* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */ chapters.length > 1 && ( @@ -394,3 +429,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
); } + +/** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */ +function SearchPanel({ + query, + hits, + chapters, + onQuery, + onJump, +}: { + query: string; + hits: SearchHit[] | null; + chapters: { title: string; start: number }[]; + onQuery: (q: string) => void; + onJump: (h: SearchHit) => void; +}) { + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); // autofocus 以 effect 实现,规避 jsx-a11y/no-autofocus 与打断首帧渲染 + return ( +
+ onQuery(e.target.value)} + aria-label="搜索全书" + /> +
+ {hits == null &&

输入关键词检索全书

} + {hits != null && hits.length === 0 &&

无结果

} + {hits != null && hits.length > 0 && ( +
    + {hits.map((h, i) => { + const { before, match, after } = splitHighlight(h.snippet, h.matchStart, h.matchLen); + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +}