"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ChevronRight, Search, X } from "lucide-react"; import Modal from "@/components/Modal"; import { libraryDocReadPath, type UrlStyle } from "@/lib/urlStyle"; import { searchBook, type BookChapterIndex, type BookSnippet } from "@/lib/bookSearch"; /** 阅读页正文章节容器 id(read/page.tsx 的
),高亮作用域 */ const ARTICLE_ID = "j13-book-article"; /** 关键词长度上限(避免超长输入卡顿) */ const MAX_QUERY = 64; /** CSS Custom Highlight 注册名(对应 globals.css 的 ::highlight(...)) */ const HL_ALL = "j13-find"; const HL_CURRENT = "j13-find-current"; /** 特性检测:CSS Custom Highlight API(Chrome105+/Safari17.2+/FF140+),不支持时只跳转不高亮 */ function highlightsSupported(): boolean { return ( typeof window !== "undefined" && typeof window.Highlight !== "undefined" && typeof CSS !== "undefined" && !!CSS.highlights ); } function clearHighlights(): void { if (!highlightsSupported()) return; CSS.highlights!.delete(HL_ALL); CSS.highlights!.delete(HL_CURRENT); } /** * 运行时注入高亮样式:构建器的 CSS 压缩器不识别 ::highlight() 会丢规则, * 且 highlight 伪元素不继承自定义属性,这里从 :root 读出 --accent/--accent-on * 的实际色值拼规则。hex 转带透明度的 rgba;非 hex 值原样使用。 */ let highlightStyleInjected = false; function ensureHighlightStyle(): void { if (highlightStyleInjected || !highlightsSupported()) return; const css = getComputedStyle(document.documentElement); const accent = (css.getPropertyValue("--accent") || "").trim(); const accentOn = (css.getPropertyValue("--accent-on") || "").trim() || "#fff"; let h = accent; if (h.length === 4) h = "#" + h.slice(1).split("").map((c) => c + c).join(""); const soft = /^#[0-9a-f]{6}$/i.test(h) ? `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},0.24)` : accent; const style = document.createElement("style"); style.dataset.j13 = "find-highlight"; style.textContent = `::highlight(${HL_ALL}){background-color:${soft}}` + `::highlight(${HL_CURRENT}){background-color:${accent};color:${accentOn}}`; document.head.appendChild(style); highlightStyleInjected = true; } /** * 收集正文内命中 Range:按文本节点逐个匹配(大小写不敏感子串),不改 DOM。 * 用 Range 而非包 :正文是 server 组件渲染的,直接替换文本节点会让 * React 在章节切换后的原地复用错位;Range 随章节重建自然失效,无副作用。 * 跨标签边界的词匹配不到——可接受,绝大多数中文命中落在同一文本节点。 */ function collectRanges(root: HTMLElement, query: string): Range[] { const q = query.trim().toLowerCase(); if (!q) return []; const targets: Text[] = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const el = node.parentElement; if (!el || !node.nodeValue) return NodeFilter.FILTER_REJECT; const tag = el.tagName; if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") { return NodeFilter.FILTER_REJECT; } return node.nodeValue.toLowerCase().includes(q) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); let n: Node | null; while ((n = walker.nextNode())) targets.push(n as Text); const ranges: Range[] = []; for (const textNode of targets) { const tv = textNode.nodeValue ?? ""; const low = tv.toLowerCase(); let from = 0; for (;;) { const idx = low.indexOf(q, from); if (idx < 0) break; const range = document.createRange(); range.setStart(textNode, idx); range.setEnd(textNode, idx + q.length); ranges.push(range); from = idx + q.length; } } return ranges; } /** 注册高亮(全部命中 + 当前命中两组);返回文档序命中 Range 列表 */ function paintRanges(query: string, currentOrdinal: number): Range[] { const root = document.getElementById(ARTICLE_ID); if (!root) return []; const ranges = collectRanges(root, query); if (!highlightsSupported()) return ranges; ensureHighlightStyle(); clearHighlights(); if (ranges.length > 0) { const all = new Highlight(...ranges); const cur = new Highlight(); const curRange = ranges[Math.min(Math.max(currentOrdinal, 0), ranges.length - 1)]; if (curRange) cur.add(curRange); CSS.highlights!.set(HL_ALL, all); CSS.highlights!.set(HL_CURRENT, cur); } return ranges; } /** 滚动到命中 Range(视口垂直居中,留出吸顶头空间) */ function scrollToRange(range: Range, smooth: boolean): void { const rect = range.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; const top = window.scrollY + rect.top - window.innerHeight / 2 + (rect.height || 16) / 2; window.scrollTo({ top: Math.max(0, top), behavior: smooth ? "smooth" : "auto" }); } /** 重新高亮当前章并定位到指定命中(ordinal 为章内 0 基命中序号) */ function highlightAndScroll(query: string, ordinal: number, smooth: boolean): void { const ranges = paintRanges(query, ordinal); const target = ranges[Math.min(Math.max(ordinal, 0), ranges.length - 1)]; if (!target) return; // 双层 rAF:等章节切换后的布局与图片占位稳定再定位 requestAnimationFrame(() => requestAnimationFrame(() => scrollToRange(target, smooth)) ); } function SnippetText({ snip }: { snip: BookSnippet }) { return ( {snip.before ? …{snip.before} : null} {snip.match} {snip.after ? {snip.after}… : null} ); } export interface BookSearchProps { slug: string; urlStyle: UrlStyle; /** 当前章序号(1 基) */ currentNo: number; chapters: BookChapterIndex[]; } export default function BookSearch({ slug, urlStyle, currentNo, chapters }: BookSearchProps) { const router = useRouter(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const inputRef = useRef(null); /** * 跨章跳转待执行的高亮。组件跨章导航不重挂载(位置稳定),query 状态保留, * 仅在 currentNo 变化、新章 DOM 提交后执行一次,因此无需把关键词写进 URL。 */ const pendingRef = useRef<{ q: string; ordinal: number } | null>(null); const trimmed = query.trim(); const results = useMemo(() => searchBook(chapters, trimmed), [chapters, trimmed]); const totalHits = useMemo(() => results.reduce((acc, h) => acc + h.count, 0), [results]); useEffect(() => { const pending = pendingRef.current; if (!pending) return; pendingRef.current = null; highlightAndScroll(pending.q, pending.ordinal, false); }, [currentNo]); // 关键词清空或离开读页时撤掉正文高亮 useEffect(() => { if (!trimmed) clearHighlights(); }, [trimmed]); useEffect(() => () => clearHighlights(), []); const focusHit = (no: number, ordinal: number) => { if (no === currentNo) { highlightAndScroll(trimmed, ordinal, true); setOpen(false); return; } pendingRef.current = { q: trimmed, ordinal }; setOpen(false); router.push(libraryDocReadPath(urlStyle, slug, no)); }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && results[0]) { const first = results[0]; focusHit(first.no, first.snippets[0]?.ordinal ?? 0); } }; return ( <> setOpen(false)} title="搜索本书" description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`} maxWidthClass="max-w-lg" panelClassName="p-5 sm:p-6" >
setQuery(e.target.value)} onKeyDown={onKeyDown} placeholder="搜索章节标题与正文" aria-label="搜索章节标题与正文" className="j13-search-field w-full rounded-xl pl-10 pr-9 py-2.5 text-[13.5px]" style={{ background: "var(--panel)", border: "1px solid var(--line)" }} /> {query ? ( ) : null}
{!trimmed ? (

输入关键词,按章查看命中片段,点击即可跳转

) : totalHits === 0 ? (

全书没有找到「{trimmed}」

) : (

{results.length} 章命中 · 共 {totalHits} 处

{results.map((hit) => { const isCurrent = hit.no === currentNo; return (
{hit.snippets.map((snip) => ( ))}
); })}
)}
); }