feat(frontend): text reader search + typography, chrome migration (B2)
This commit is contained in:
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Added / 新增
|
### 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 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)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
|
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { expect } from "vitest";
|
|||||||
import { beforeEach, describe, it } from "vitest";
|
import { beforeEach, describe, it } from "vitest";
|
||||||
import { ReaderSettings } from "@/components/reader-settings";
|
import { ReaderSettings } from "@/components/reader-settings";
|
||||||
import { ThemeProvider } from "@/components/theme";
|
import { ThemeProvider } from "@/components/theme";
|
||||||
import { DEFAULT_PREFS } from "@/lib/readerPrefs";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -48,11 +47,16 @@ describe("ReaderSettings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("showFont=false 时无 A−/A+", () => {
|
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();
|
||||||
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 可见性)", () => {
|
it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
|
||||||
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
|
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
|
||||||
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
|
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
|
||||||
@@ -63,10 +67,10 @@ describe("ReaderSettings", () => {
|
|||||||
expect(screen.getByTestId("extra")).toBeInTheDocument();
|
expect(screen.getByTestId("extra")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("showFont 时 A+ 点击 → text.sizeIdx 增加", () => {
|
it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => {
|
||||||
renderSettings({ showFont: true });
|
let got = 0;
|
||||||
|
renderSettings({ showFont: true, onFont: (d: number) => (got = d) });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
|
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
|
||||||
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
|
expect(got).toBe(1);
|
||||||
expect(stored.text.sizeIdx).toBe(DEFAULT_PREFS.text.sizeIdx + 1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ReaderSettingsProps {
|
|||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
|
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
|
||||||
showFont?: boolean;
|
showFont?: boolean;
|
||||||
|
/** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */
|
||||||
|
onFont?: (d: number) => void;
|
||||||
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
|
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,7 @@ export interface ReaderSettingsProps {
|
|||||||
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||||
|
|
||||||
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
|
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ 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 pr = useReaderPrefs();
|
||||||
const globalDark = useGlobalDark();
|
const globalDark = useGlobalDark();
|
||||||
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
@@ -68,14 +70,14 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{showFont && (
|
{showFont && onFont && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
aria-label="减小字号"
|
aria-label="减小字号"
|
||||||
disabled={pr.prefs.text.sizeIdx === 0}
|
disabled={pr.prefs.text.sizeIdx === 0}
|
||||||
onClick={() => pr.bumpText(-1)}
|
onClick={() => onFont(-1)}
|
||||||
>
|
>
|
||||||
A−
|
A−
|
||||||
</Button>
|
</Button>
|
||||||
@@ -84,7 +86,7 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra
|
|||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
aria-label="加大字号"
|
aria-label="加大字号"
|
||||||
disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1}
|
disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1}
|
||||||
onClick={() => pr.bumpText(1)}
|
onClick={() => onFont(1)}
|
||||||
>
|
>
|
||||||
A+
|
A+
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,3 +116,13 @@ export function ReaderSettings({ slider, position, left, showFont = false, extra
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置行通用骨架:label + 右侧控件组。 */
|
||||||
|
export function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="flex items-center gap-1">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
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 { api, formatPageUrl } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
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 (
|
|
||||||
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
<span className="flex items-center gap-1">{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Chrome = { on: boolean; toggle: () => void; show: () => void };
|
type Chrome = { on: boolean; toggle: () => void; show: () => void };
|
||||||
|
|
||||||
/** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */
|
/** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */
|
||||||
|
|||||||
+211
-117
@@ -1,27 +1,20 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
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 { apiRaw } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
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 { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||||
import { decodeText } from "../lib/encoding";
|
import { decodeText } from "../lib/encoding";
|
||||||
import {
|
import { LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
|
||||||
FONT_SIZES,
|
import { searchChapters, splitHighlight, type SearchHit } from "../lib/search";
|
||||||
RD_THEMES,
|
|
||||||
RD_THEME_LABEL,
|
|
||||||
resolveRdTheme,
|
|
||||||
useReaderPrefs,
|
|
||||||
type RdTheme,
|
|
||||||
} from "../lib/readerPrefs";
|
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
|
||||||
|
|
||||||
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
|
||||||
|
|
||||||
function useFileText(url: string) {
|
function useFileText(url: string) {
|
||||||
const [text, setText] = useState<string | null>(null);
|
const [text, setText] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
@@ -38,9 +31,22 @@ function useFileText(url: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let dead = false;
|
let dead = false;
|
||||||
apiRaw(url)
|
apiRaw(url)
|
||||||
.then((r) => r.arrayBuffer())
|
.then((r) => {
|
||||||
.then((b) => !dead && setText(decodeText(b)))
|
if (dead) return;
|
||||||
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
|
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 () => {
|
return () => {
|
||||||
dead = true;
|
dead = true;
|
||||||
};
|
};
|
||||||
@@ -50,89 +56,19 @@ function useFileText(url: string) {
|
|||||||
|
|
||||||
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
|
<p className="mb-3">{text}</p>
|
||||||
{!onRetry && <p className="text-stone-500">{text}</p>}
|
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button className={btn} onClick={onRetry}>
|
<Button variant="secondary" onClick={onRetry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
|
|
||||||
function ReaderSheet({
|
|
||||||
pr,
|
|
||||||
frac,
|
|
||||||
onSeek,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
pr: Prefs;
|
|
||||||
frac: number;
|
|
||||||
onSeek: (f: number) => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rd-sheet rd-divider relative z-20 shrink-0 border-t px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1000}
|
|
||||||
value={Math.round(frac * 1000)}
|
|
||||||
onChange={(e) => onSeek(Number(e.target.value) / 1000)}
|
|
||||||
className="rd-range"
|
|
||||||
aria-label="阅读进度"
|
|
||||||
/>
|
|
||||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">{Math.round(frac * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
{children}
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
|
||||||
<div className="flex items-center gap-1.5" role="group" aria-label="阅读主题">
|
|
||||||
{RD_THEMES.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
|
||||||
aria-pressed={resolveRdTheme(pr.prefs.themeMode, false) === t}
|
|
||||||
onClick={() => pr.setThemeMode(t)}
|
|
||||||
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
|
||||||
style={{
|
|
||||||
background: SWATCH[t],
|
|
||||||
boxShadow:
|
|
||||||
resolveRdTheme(pr.prefs.themeMode, false) === t
|
|
||||||
? "0 0 0 2px var(--rd-link)"
|
|
||||||
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
onClick={() => pr.bumpText(-1)}
|
|
||||||
disabled={pr.prefs.text.sizeIdx === 0}
|
|
||||||
aria-label="减小字号"
|
|
||||||
>
|
|
||||||
A−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
onClick={() => pr.bumpText(1)}
|
|
||||||
disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1}
|
|
||||||
aria-label="加大字号"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
||||||
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
||||||
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
|
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 [nav, setNav] = useState(false);
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
|
||||||
@@ -183,7 +121,7 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null) return <LoadState text="正文加载中…" />;
|
if (text == null) return <LoadState text="正文加载中…" />;
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -191,22 +129,41 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
className="md-body mx-auto max-w-[46rem]"
|
||||||
style={{ fontSize: pr.fontSize }}
|
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}
|
||||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
|
<ReaderSettings
|
||||||
<button
|
slider={{
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
value: Math.round(frac * 1000),
|
||||||
|
max: 1000,
|
||||||
|
onChange: (v) => seek(v / 1000),
|
||||||
|
ariaLabel: "阅读进度",
|
||||||
|
}}
|
||||||
|
position={`${Math.round(frac * 100)}%`}
|
||||||
|
left={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
aria-expanded={nav}
|
aria-expanded={nav}
|
||||||
aria-controls="reader-nav"
|
aria-controls="reader-nav"
|
||||||
onClick={() => setNav((v) => !v)}
|
onClick={() => setNav((v) => !v)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={14} /> 导航
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</Button>
|
||||||
</ReaderSheet>
|
}
|
||||||
|
showFont
|
||||||
|
onFont={pr.bumpText}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
@@ -228,6 +185,40 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 行距五档按钮组(text/epub 各自消费 lineIdx)。 */
|
||||||
|
function LineHeightRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; 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 (
|
||||||
|
<SettingRow label="行距">
|
||||||
|
{LINE_HEIGHTS.map((lh, i) => (
|
||||||
|
<Button key={i} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
|
||||||
|
{lh}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 边距三档按钮组(text/epub 各自消费 margin/marginIdx)。 */
|
||||||
|
function MarginRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; 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 (
|
||||||
|
<SettingRow label="边距">
|
||||||
|
{MARGINS.map((m, i) => (
|
||||||
|
<Button key={m} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
|
||||||
|
{MARGIN_LABEL[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||||
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -239,7 +230,11 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
const [ci, setCi] = useState(0);
|
const [ci, setCi] = useState(0);
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
const [nav, setNav] = useState<null | NavTab>(null);
|
const [nav, setNav] = useState<null | NavTab>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
|
||||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
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" });
|
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [nav]);
|
}, [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) {
|
function goChapter(i: number, intra = 0) {
|
||||||
if (!chapters) return;
|
if (!chapters) return;
|
||||||
restoreFrac.current = intra > 0 ? intra : null;
|
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;
|
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -314,44 +318,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
||||||
>
|
>
|
||||||
<pre
|
<pre
|
||||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
className="mx-auto break-words whitespace-pre-wrap"
|
||||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight, textAlign: "justify", maxWidth: pr.marginMaxW }}
|
||||||
>
|
>
|
||||||
{chapterText(text, chapters, ci)}
|
{chapterText(text, chapters, ci)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
<ReaderSettings
|
||||||
<button
|
slider={{
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
value: Math.round(total * 1000),
|
||||||
|
max: 1000,
|
||||||
|
onChange: (v) => seek(v / 1000),
|
||||||
|
ariaLabel: "阅读进度",
|
||||||
|
}}
|
||||||
|
position={`${Math.round(total * 100)}%`}
|
||||||
|
left={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
aria-expanded={!!nav}
|
aria-expanded={!!nav}
|
||||||
aria-controls="reader-nav"
|
aria-controls="reader-nav"
|
||||||
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
|
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
|
||||||
>
|
>
|
||||||
<PanelLeft size={14} /> 导航
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</Button>
|
||||||
{chapters.length > 1 && (
|
{chapters.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs opacity-60 tabular-nums">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
{ci + 1}/{chapters.length}
|
{ci + 1}/{chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button variant="ghost" size="sm" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
|
||||||
disabled={ci === 0}
|
|
||||||
onClick={() => goChapter(ci - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> 上一章
|
<ChevronLeft size={14} /> 上一章
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
disabled={ci === chapters.length - 1}
|
disabled={ci === chapters.length - 1}
|
||||||
onClick={() => goChapter(ci + 1)}
|
onClick={() => goChapter(ci + 1)}
|
||||||
>
|
>
|
||||||
下一章 <ChevronRight size={14} />
|
下一章 <ChevronRight size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ReaderSheet>
|
</>
|
||||||
|
}
|
||||||
|
showFont
|
||||||
|
onFont={pr.bumpText}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
@@ -368,6 +390,19 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
search={
|
||||||
|
<SearchPanel
|
||||||
|
query={query}
|
||||||
|
hits={hits}
|
||||||
|
chapters={chapters}
|
||||||
|
onQuery={setQuery}
|
||||||
|
onJump={(h) => {
|
||||||
|
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={
|
toc={
|
||||||
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
|
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
|
||||||
chapters.length > 1 && (
|
chapters.length > 1 && (
|
||||||
@@ -394,3 +429,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */
|
||||||
|
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<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []); // autofocus 以 effect 实现,规避 jsx-a11y/no-autofocus 与打断首帧渲染
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="搜索全书"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
aria-label="搜索全书"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{hits == null && <p className="p-2 text-xs text-muted-foreground">输入关键词检索全书</p>}
|
||||||
|
{hits != null && hits.length === 0 && <p className="p-2 text-xs text-muted-foreground">无结果</p>}
|
||||||
|
{hits != null && hits.length > 0 && (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{hits.map((h, i) => {
|
||||||
|
const { before, match, after } = splitHighlight(h.snippet, h.matchStart, h.matchLen);
|
||||||
|
return (
|
||||||
|
<li key={`${h.ch}-${h.pos}-${i}`}>
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onClick={() => onJump(h)}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{i + 1}. {chapters[h.ch].title}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate">
|
||||||
|
{before}
|
||||||
|
<mark className="rounded px-0.5 bg-accent text-accent-foreground">{match}</mark>
|
||||||
|
{after}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user