Files
jiang13-bbs/frontend/components/markdown/MdCodeBlock.tsx
freefire 2dd9cb82b3 fix(frontend): 修复 aardio 注释着色、代码行距与折叠 hydration
注释不再被运算符拆开;去掉 grid 行 min-height;挂载后再折叠以避免 SSR/CSR 不一致。

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

259 lines
8.5 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";
const COLLAPSE_LINE_THRESHOLD = 5;
/** 约 5 行代码高度(含行高) */
const COLLAPSED_MAX_HEIGHT = "7.75rem";
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 代码块:语言标签、复制、>5 行默认折叠;客户端仅在未真正着色时补色 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
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 = foldReady && effectiveLines > COLLAPSE_LINE_THRESHOLD;
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) {
setExpanded(false);
// 收起后滚到该代码块顶部(含工具栏),便于继续往下读
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: COLLAPSED_MAX_HEIGHT } : 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>
);
}