feat(reader): immersive chrome + WeChat-Read/Mihon-style bottom sheets — tap-center toggles header/toolbar, progress & page sliders seek anywhere, WYSIWYG theme swatches, A± font size; CBZ gains 连读/整页/适高 modes, edge-tap & arrow-key paging, persisted mode (seekChapter helper + test)
This commit is contained in:
@@ -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<typeof useReaderPrefs>;
|
||||
|
||||
function PrefsBar({ pr }: { pr: Prefs }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm" role="group" aria-label="阅读偏好">
|
||||
<button className="rd-btn text-xs" onClick={pr.cycleTheme} aria-label="切换阅读主题">
|
||||
{RD_THEME_LABEL[pr.prefs.theme]}·主题
|
||||
</button>
|
||||
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn"
|
||||
onClick={() => pr.bump(1)}
|
||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
||||
aria-label="加大字号"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="rd-divider flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
|
||||
{children}
|
||||
<div className="ml-auto">
|
||||
<PrefsBar pr={pr} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||
|
||||
function useFileText(url: string) {
|
||||
const [text, setText] = useState<string | null>(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 (
|
||||
<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={pr.prefs.theme === t}
|
||||
onClick={() => pr.setTheme(t)}
|
||||
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
background: SWATCH[t],
|
||||
boxShadow:
|
||||
pr.prefs.theme === 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.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn"
|
||||
onClick={() => pr.bump(1)}
|
||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
||||
aria-label="加大字号"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
||||
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, 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" ? <MdView {...props} /> : <TxtView {...props} />;
|
||||
}
|
||||
|
||||
function MdView({ book, initialLocator }: ReaderProps) {
|
||||
function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(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 <LoadState text={err} onRetry={retry} />;
|
||||
if (text == null) return <LoadState text="正文加载中…" />;
|
||||
return (
|
||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<ReaderToolbar pr={pr} />
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<div
|
||||
ref={boxRef}
|
||||
onScroll={onScroll}
|
||||
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
||||
>
|
||||
<article
|
||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
||||
style={{ fontSize: pr.fontSize }}
|
||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||
/>
|
||||
</div>
|
||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||
function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(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 <LoadState text={err} onRetry={retry} />;
|
||||
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
||||
|
||||
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||
|
||||
return (
|
||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<ReaderToolbar pr={pr}>
|
||||
{chapters.length > 1 && (
|
||||
<>
|
||||
<button className="rd-btn" aria-expanded={toc} aria-controls="toc-panel" onClick={() => setToc((v) => !v)}>
|
||||
目录
|
||||
</button>
|
||||
<span className="text-xs opacity-60 tabular-nums">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</ReaderToolbar>
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6">
|
||||
<div
|
||||
ref={boxRef}
|
||||
onScroll={onScroll}
|
||||
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
||||
>
|
||||
<pre
|
||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
||||
@@ -213,15 +277,30 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
{chapterText(text, chapters, ci)}
|
||||
</pre>
|
||||
</div>
|
||||
{chapters.length > 1 && (
|
||||
<div className="rd-divider flex shrink-0 items-center justify-center gap-3 border-t px-4 py-2 text-sm">
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||
← 上一章
|
||||
</button>
|
||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章 →
|
||||
</button>
|
||||
</div>
|
||||
{chrome.on && (
|
||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||
{chapters.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="rd-btn"
|
||||
aria-expanded={toc}
|
||||
aria-controls="toc-panel"
|
||||
onClick={() => setToc((v) => !v)}
|
||||
>
|
||||
目录
|
||||
</button>
|
||||
<span className="text-xs opacity-60 tabular-nums">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||
← 上一章
|
||||
</button>
|
||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章 →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</ReaderSheet>
|
||||
)}
|
||||
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
|
||||
{toc && chapters.length > 1 && (
|
||||
|
||||
Reference in New Issue
Block a user