Files
jiang13-bbs/frontend/components/markdown/MdCodeBlock.tsx
freefire 809d8f1df7 feat(settings): 后台可配置 Markdown 代码块自动折叠与阈值行数
支持开关与 0–100 行阈值(0 为无预览),经站点设置与 WS 热更新;短代码在视口内收起时不再回滚浏览位置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 06:43:37 +08:00

272 lines
9.1 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 {
useMemo,
useRef,
useState,
useEffect,
type HTMLAttributes,
type ReactNode,
type ReactElement,
} from "react";
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
import { useCodeFoldConfig } from "./CodeFoldProvider";
/** 折叠预览高度:0=无预览;否则上下 padding ≈1.7rem + 每行 ≈1.21em */
function collapsedMaxHeight(foldLines: number): string {
if (foldLines <= 0) return "0px";
return `calc(1.7rem + ${foldLines} * 1.21em)`;
}
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
children?: ReactNode;
};
function isReactElement(
node: ReactNode
): node is ReactElement<{ children?: ReactNode; className?: string }> {
return !!node && typeof node === "object" && "props" in node;
}
/** 按 data-line / 块级结构补换行,避免行间文本被拼成一行 */
function extractText(node: ReactNode): string {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) {
return node
.map((child, i) => {
const text = extractText(child);
if (
i < node.length - 1 &&
isReactElement(child) &&
hasLineMarker(child)
) {
return text.endsWith("\n") ? text : `${text}\n`;
}
return text;
})
.join("");
}
if (isReactElement(node)) {
return extractText(node.props.children);
}
return "";
}
function hasLineMarker(node: ReactElement): boolean {
const props = node.props as Record<string, unknown>;
return (
props["data-line"] != null ||
(typeof props.className === "string" &&
/\bline\b/.test(props.className))
);
}
function countLines(pre: HTMLElement | null, fallbackText: string): number {
if (pre) {
// pretty-code / shiki:一行一个 [data-line] 或 .line,挂在 code 直下
const direct = pre.querySelectorAll(":scope > code > [data-line], :scope > code > .line");
if (direct.length > 0) return direct.length;
}
const text = fallbackText.replace(/\n$/, "");
if (!text) return 0;
return text.split("\n").length;
}
function readLanguage(
el: HTMLElement | null,
className?: string,
dataLanguage?: string
): string {
const fromData = dataLanguage || el?.getAttribute("data-language") || "";
if (fromData) return fromData;
const cls = [className, el?.className].filter(Boolean).join(" ");
const m = cls.match(/language-([\w+-]+)/);
if (m?.[1]) return m[1];
// react-markdown:语言在子 code 上
const childCode = el?.querySelector(":scope > code");
const childCls = childCode?.className || "";
const m2 = childCls.match(/language-([\w+-]+)/);
return m2?.[1] || "";
}
function readProp(
rest: Record<string, unknown>,
key: string
): string | undefined {
const v = rest[key];
return typeof v === "string" ? v : undefined;
}
/** 子树里是否已有 Shiki 双主题 token(--shiki-light),用于判断 SSR 是否真正着色成功 */
function hasShikiTokens(node: ReactNode): boolean {
if (node == null || typeof node === "boolean") return false;
if (Array.isArray(node)) return node.some(hasShikiTokens);
if (!isReactElement(node)) return false;
const props = node.props as Record<string, unknown>;
const style = props.style;
if (typeof style === "string" && style.includes("--shiki-light")) return true;
if (
style &&
typeof style === "object" &&
("--shiki-light" in (style as object) ||
Object.values(style as Record<string, unknown>).some(
(v) => typeof v === "string" && String(v).includes("#")
))
) {
// React 可能把 CSS 变量写成 style={{ ['--shiki-light']: '#xxx' }}
const rec = style as Record<string, unknown>;
if ("--shiki-light" in rec || "color" in rec) {
// 有 --shiki-light 即视为已着色;仅有 color 不够(可能是别的)
if ("--shiki-light" in rec) return true;
}
for (const k of Object.keys(rec)) {
if (k.includes("shiki")) return true;
}
}
return hasShikiTokens(props.children as ReactNode);
}
/** Markdown 代码块:语言标签、复制、超阈值行数默认折叠;客户端仅在未真正着色时补色 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const { enabled: autoFold, foldLines } = useCodeFoldConfig();
const restMap = rest as Record<string, unknown>;
const dataLanguage = readProp(restMap, "data-language");
const plainText = useMemo(() => extractText(children), [children]);
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
// 以是否存在 --shiki-light token 为准;勿仅凭 data-language(失败回退 plaintext 时仍带该属性)
const serverHighlighted = useMemo(() => hasShikiTokens(children), [children]);
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
/** 挂载后再启用折叠,避免 SSR/CSR 行数统计不一致导致 hydration mismatch */
const [foldReady, setFoldReady] = useState(false);
const [lineCount, setLineCount] = useState(0);
const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage)
);
const [clientHtml, setClientHtml] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage));
setFoldReady(true);
}, [plainText, className, dataLanguage, clientHtml]);
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
useEffect(() => {
if (serverHighlighted || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
let cancelled = false;
(async () => {
try {
const { siteCodeToHtml } = await import("./shikiHighlighter");
const html = await siteCodeToHtml(plainText.replace(/\n$/, ""), lang);
if (!cancelled) setClientHtml(html);
} catch {
// 未知语言 / 加载失败:保持纯文本
}
})();
return () => {
cancelled = true;
};
}, [plainText, className, dataLanguage, serverHighlighted]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = autoFold && foldReady && effectiveLines > foldLines;
const collapsed = canCollapse && !expanded;
const onCopy = async () => {
const pre = hostRef.current?.querySelector("pre");
const text =
pre?.innerText?.replace(/\n$/, "") || plainText.replace(/\n$/, "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
toast("复制失败,请手动选择文本");
}
};
const toggleFold = () => {
if (expanded) {
const el = rootRef.current;
// 仅当代码块顶部已滚出可视区(长代码展开后往下读)才回顶;
// 短代码全程在视口内时保持浏览位置不变
let shouldScrollBack = false;
if (el) {
const top = el.getBoundingClientRect().top;
const headerPad = 88; // 与 scroll-margin-top: 5.5rem 对齐
shouldScrollBack = top < headerPad;
}
setExpanded(false);
if (shouldScrollBack) {
requestAnimationFrame(() => {
rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
});
}
} else {
setExpanded(true);
}
};
const preClass = ["md-code-pre", "shiki", className].filter(Boolean).join(" ");
return (
<div className="md-code-block" ref={rootRef}>
<div className="md-code-toolbar">
<span className="md-code-lang">{language || "text"}</span>
<button
type="button"
className="md-code-copy"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<div
ref={hostRef}
className={`md-code-body${collapsed ? " md-code-collapsed" : ""}`}
style={collapsed ? { maxHeight: collapsedMaxHeight(foldLines) } : undefined}
>
{clientHtml ? (
<div
className="md-code-shiki-host"
dangerouslySetInnerHTML={{ __html: clientHtml }}
/>
) : (
<pre className={preClass} {...rest}>
{children}
</pre>
)}
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button type="button" className="md-code-fold" onClick={toggleFold}>
{expanded ? (
<>
<ChevronUp size={14} />
收起
</>
) : (
<>
<ChevronDown size={14} />
展开全部({effectiveLines} 行)
</>
)}
</button>
)}
</div>
);
}