Files
jiang13-bbs/frontend/components/markdown/MdCodeBlock.tsx
freefire be47b38370 fix(frontend): 修复 Markdown 代码块折叠空白,收起滚回块顶
避免客户端覆盖 SSR 高亮;空行撑高;收起后 scrollIntoView 到代码块头部。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:50:40 +08:00

239 lines
7.4 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;
}
/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端仅在未 SSR 高亮时补色 */
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 dataTheme = readProp(restMap, "data-theme");
const plainText = useMemo(() => extractText(children), [children]);
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
// rehype-pretty-code 会去掉 pre 上的 shiki class,但会保留 data-theme / data-language
const serverHighlighted =
(typeof className === "string" && className.includes("shiki")) ||
!!dataTheme ||
!!dataLanguage;
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [lineCount, setLineCount] = useState(approxLines);
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));
}, [plainText, className, dataLanguage, clientHtml]);
// 仅客户端预览(无 SSR 高亮)时动态补双主题;切勿覆盖服务端已渲染内容
useEffect(() => {
if (serverHighlighted || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
let cancelled = false;
(async () => {
try {
const { codeToHtml } = await import("shiki");
const html = await codeToHtml(plainText.replace(/\n$/, ""), {
lang: lang === "text" ? "plaintext" : lang,
themes: {
light: "github-light",
dark: "github-dark-dimmed",
},
defaultColor: false,
});
if (!cancelled) setClientHtml(html);
} catch {
// 未知语言:保持纯文本
}
})();
return () => {
cancelled = true;
};
}, [plainText, className, dataLanguage, serverHighlighted]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = 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", 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>
);
}