"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 & { 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; 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, 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(null); const hostRef = useRef(null); const restMap = rest as Record; 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(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 (
{language || "text"}
{clientHtml ? (
) : (
            {children}
          
)} {collapsed && {canCollapse && ( )}
); }