import DOMPurify from "dompurify"; import { marked } from "marked"; import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-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 { 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 { 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"; function useFileText(url: string) { const [text, setText] = useState(null); const [err, setErr] = useState(""); const [nonce, setNonce] = useState(0); // url/nonce 变化时先落回加载态:React 官方推荐的「渲染期按下发 key 重置本地 state」, // 替代原先 effect 内的同步 setState(级联渲染);重试时的表现与原先一致 const key = url + "#" + nonce; const [prevKey, setPrevKey] = useState(key); if (prevKey !== key) { setPrevKey(key); setText(null); setErr(""); } useEffect(() => { let dead = false; apiRaw(url) .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; }; }, [url, nonce]); return { text, err, retry: () => setNonce((n) => n + 1) }; } function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) { return (

{text}

{onRetry && ( )}
); } /** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */ 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, initialPercent, chrome }: ReaderProps) { const boxRef = useRef(null); const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); 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 ?? ""); const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]); useEffect(() => { if (restored.current || text == null || !boxRef.current) return; restored.current = true; const f = Number(initialLocator?.scrollFraction); if (Number.isFinite(f) && f > 0 && f <= 1) { const el = boxRef.current; el.scrollTop = f * (el.scrollHeight - el.clientHeight); } }, [text, initialLocator]); function onScroll() { const el = boxRef.current; if (!el) return; const max = el.scrollHeight - el.clientHeight; 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 (
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;正文滚轮/拖选不涉及键盘操作 */}
bodyTogglesChrome(e, chrome.toggle)} className="min-h-0 flex-1 overflow-y-auto px-6 py-6" >
{chrome.on && ( seek(v / 1000), ariaLabel: "阅读进度", }} position={`${Math.round(frac * 100)}%`} left={ } showFont onFont={pr.bumpText} fontIdx={pr.prefs.text.sizeIdx} extra={ <> } /> )} {nav && ( setNav(false)} bookmarks={ { seek(Number(l.scrollFraction) || 0); setNav(false); }} /> } /> )}
); } /** 行距五档按钮组(text/epub 各自消费 lineIdx)。 */ function LineHeightRow({ pr, epub = false }: { pr: ReturnType; 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);
  const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
  const inited = useRef(false);
  const restoreFrac = useRef(null);
  const targetCi = useRef(0);
  const activeRow = useRef(null);
  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]);

  useEffect(() => {
    if (inited.current || !chapters) return;
    inited.current = true;
    const f = Number(initialLocator?.scrollFraction);
    const hasF = Number.isFinite(f) && f > 0 && f <= 1;
    const rawCh = Number(initialLocator?.ch);
    if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
      targetCi.current = Math.floor(rawCh);
      // eslint-disable-next-line react-hooks/set-state-in-effect -- 首章渲染后按缓存 locator 落位阅读进度:ci 落位与下方滚动定位 effect/refs 协作,生命周期与恢复进度语义同源,保留带理由
      setCi(Math.floor(rawCh));
      if (hasF) restoreFrac.current = f;
    } else if (hasF) {
      const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
      targetCi.current = idx;
      setCi(idx);
      if (intra > 0) restoreFrac.current = intra;
    }
  }, [chapters, initialLocator]);

  useEffect(() => {
    const el = boxRef.current;
    if (!el || !chapters || ci !== targetCi.current) return;
    if (restoreFrac.current != null) {
      el.scrollTop = restoreFrac.current * (el.scrollHeight - el.clientHeight);
      restoreFrac.current = null;
    } else el.scrollTop = 0;
  }, [ci, chapters]);

  useEffect(() => {
    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;
    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 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 (
    
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;点按是唯一唤出途径,键盘用户由常驻工具条提供全部控制 */}
bodyTogglesChrome(e, chrome.toggle)} className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6" >
          {chapterText(text, chapters, ci)}
        
{chrome.on && ( seek(v / 1000), ariaLabel: "阅读进度", }} position={`${Math.round(total * 100)}%`} left={ <> {chapters.length > 1 && ( <> {ci + 1}/{chapters.length} )} } showFont onFont={pr.bumpText} fontIdx={pr.prefs.text.sizeIdx} extra={ <> } /> )} {nav && ( 1 ? nav : "bm"} onTab={setNav} onClose={() => setNav(null)} bookmarks={ { goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0); setNav(null); }} /> } 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 && (
    {chapters.map((c, i) => (
  • ))}
) } /> )}
); } /** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */ 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 (
  • ); })}
)}
); }