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:
{text}
} - {!onRetry &&{text}
} +{text}
{onRetry && ( - )} 会冻死浏览器:按章渲染,一章一个滚动块。
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 && (
-
- setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
- >
- 导航
-
- {chapters.length > 1 && (
+ seek(v / 1000),
+ ariaLabel: "阅读进度",
+ }}
+ position={`${Math.round(total * 100)}%`}
+ left={
<>
-
- {ci + 1}/{chapters.length}
-
- goChapter(ci - 1)}
+ setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
>
- 上一章
-
- goChapter(ci + 1)}
- >
- 下一章
-
+ 导航
+
+ {chapters.length > 1 && (
+ <>
+
+ {ci + 1}/{chapters.length}
+
+ goChapter(ci - 1)}>
+ 上一章
+
+ goChapter(ci + 1)}
+ >
+ 下一章
+
+ >
+ )}
>
- )}
-
+ }
+ 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 (
+ -
+
onJump(h)}
+ >
+
+ {i + 1}. {chapters[h.ch].title}
+
+
+ {before}
+ {match}
+ {after}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}