feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
327
frontend/components/BookSearch.tsx
Normal file
327
frontend/components/BookSearch.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user