Files
jiang13-bbs/frontend/components/BookSearch.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 的 <article>),高亮作用域 */
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 而非包 <mark>:正文是 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 (
<span className="leading-relaxed">
{snip.before ? <span className="meta">…{snip.before}</span> : null}
<mark className="j13-find-mark">{snip.match}</mark>
{snip.after ? <span className="meta">{snip.after}…</span> : null}
</span>
);
}
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<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === "Enter" && results[0]) {
const first = results[0];
focusHit(first.no, first.snippets[0]?.ordinal ?? 0);
}
};
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="inline-flex items-center gap-2 rounded-full pl-3.5 pr-4 py-2 text-[13px] transition-colors hover:bg-[var(--panel-2)] hover:border-[color-mix(in_srgb,var(--accent)_40%,var(--line))] shrink-0 w-36 sm:w-52"
style={{ color: "var(--ink-3)", border: "1px solid var(--line)", background: "var(--panel)" }}
aria-label="搜索本书"
>
<Search size={14} className="shrink-0" aria-hidden />
<span className="truncate text-left">搜索本书</span>
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="搜索本书"
description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`}
maxWidthClass="max-w-lg"
panelClassName="p-5 sm:p-6"
>
<div className="relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={query}
maxLength={MAX_QUERY}
onChange={(e) => 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 ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除关键词"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
<div className="mt-3 max-h-[56vh] overflow-y-auto j13-rail-scroll -mx-1 px-1">
{!trimmed ? (
<p className="meta text-[12.5px] py-8 text-center">
输入关键词,按章查看命中片段,点击即可跳转
</p>
) : totalHits === 0 ? (
<p className="meta text-[12.5px] py-8 text-center">全书没有找到「{trimmed}」</p>
) : (
<div className="flex flex-col gap-2">
<p className="meta text-[11.5px] px-1">
{results.length} 章命中 · 共 {totalHits} 处
</p>
{results.map((hit) => {
const isCurrent = hit.no === currentNo;
return (
<section
key={hit.no}
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--line)" }}
>
<button
type="button"
onClick={() => focusHit(hit.no, 0)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<span
className="text-[12.5px] font-bold truncate min-w-0"
style={{ color: "var(--ink)" }}
>
第 {hit.no} 章 · {hit.title}
</span>
{isCurrent ? (
<span
className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
本章
</span>
) : null}
<span className="meta text-[11px] ml-auto shrink-0 inline-flex items-center gap-0.5">
{hit.count} 处
<ChevronRight size={12} aria-hidden />
</span>
</button>
{hit.snippets.map((snip) => (
<button
key={snip.ordinal}
type="button"
onClick={() => focusHit(hit.no, snip.ordinal)}
className="block w-full text-left px-3 py-1.5 text-[12.5px] border-t border-[var(--line)] hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
<SnippetText snip={snip} />
</button>
))}
</section>
);
})}
</div>
)}
</div>
</Modal>
</>
);
}