"use client"; import { useMemo, useRef, useState, useEffect, type HTMLAttributes, type ReactNode, } 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 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(extractText).join(""); if (typeof node === "object" && "props" in node) { const el = node as { props?: { children?: ReactNode } }; return extractText(el.props?.children); } return ""; } function countLines(pre: HTMLElement | null, fallbackText: string): number { if (pre) { const lineEls = pre.querySelectorAll(".line, [data-line]"); if (lineEls.length > 0) return lineEls.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+-]+)/); return m?.[1] || ""; } /** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端可补 Shiki 双主题 */ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) { const hostRef = useRef(null); const plainText = useMemo(() => extractText(children), [children]); const dataLanguage = typeof (rest as Record)["data-language"] === "string" ? String((rest as Record)["data-language"]) : undefined; const approxLines = useMemo(() => countLines(null, plainText), [plainText]); const alreadyShiki = typeof className === "string" && className.includes("shiki"); 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]); // 服务端已着色时跳过;客户端预览动态补双主题 useEffect(() => { if (alreadyShiki || !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, alreadyShiki]); 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 preClass = ["md-code-pre", className].filter(Boolean).join(" "); return (
{language || "text"}
{clientHtml ? (
) : (
            {children}
          
)} {collapsed && {canCollapse && ( )}
); }