"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 & { 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; } /** 子树里是否已有 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; 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).some( (v) => typeof v === "string" && String(v).includes("#") )) ) { // React 可能把 CSS 变量写成 style={{ ['--shiki-light']: '#xxx' }} const rec = style as Record; 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(null); const hostRef = useRef(null); const { enabled: autoFold, foldLines } = useCodeFoldConfig(); const restMap = rest as Record; 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(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 (
{language || "text"}
{clientHtml ? (
) : (
            {children}
          
)} {collapsed && {canCollapse && ( )}
); }