"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"; const FOLD_ANIM_MS = 360; /** 与 .md-code-block scroll-margin-top / sticky 顶栏对齐 */ const HEADER_PAD_PX = 88; /** 折叠预览高度:0=无预览;按实际行高测量,避免 calc 与动画像素不一致 */ function measureCollapsedPx(host: HTMLElement, foldLines: number): number { if (foldLines <= 0) return 0; const pre = host.querySelector("pre"); const line = host.querySelector(":scope pre code > [data-line], :scope pre code > .line") || host.querySelector("[data-line], .line"); const lineH = line?.getBoundingClientRect().height || 20; let padY = 0; if (pre) { const cs = getComputedStyle(pre); padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); } return Math.max(0, Math.round(padY + foldLines * lineH)); } function prefersReducedMotion(): boolean { return ( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } 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 foldBtnRef = 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); /** 动画中的 max-height(px);null 表示用稳态样式 */ const [animMaxPx, setAnimMaxPx] = useState(null); /** 折叠预览高度缓存(px),与动画终点一致 */ const [collapsedPx, setCollapsedPx] = useState(null); const animTimerRef = useRef(null); const followRafRef = useRef(null); const stopScrollFollow = () => { if (followRafRef.current != null) { cancelAnimationFrame(followRafRef.current); followRafRef.current = null; } }; /** 收起时钉住「收起」按钮视口位置,滚动随高度收缩上移,头部从上方进入视野 */ const startScrollFollow = (pinTop: number, foldEl: HTMLElement) => { stopScrollFollow(); const tick = () => { const drift = foldEl.getBoundingClientRect().top - pinTop; if (Math.abs(drift) > 0.5) { window.scrollBy({ top: drift, left: 0, behavior: "auto" }); } followRafRef.current = requestAnimationFrame(tick); }; followRafRef.current = requestAnimationFrame(tick); }; 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]); useEffect(() => { const host = hostRef.current; if (!foldReady || !host) return; setCollapsedPx(measureCollapsedPx(host, foldLines)); }, [foldReady, foldLines, clientHtml, lineCount]); useEffect(() => { return () => { if (animTimerRef.current != null) window.clearTimeout(animTimerRef.current); stopScrollFollow(); }; }, []); // 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 = () => { const host = hostRef.current; const root = rootRef.current; if (animTimerRef.current != null) { window.clearTimeout(animTimerRef.current); animTimerRef.current = null; } stopScrollFollow(); if (!host || prefersReducedMotion()) { setAnimMaxPx(null); const nextExpanded = !expanded; setExpanded(nextExpanded); // 无动画时:头部在视口外则收起后直接对齐到块顶 if (expanded && root) { const top = root.getBoundingClientRect().top; if (top < HEADER_PAD_PX) { requestAnimationFrame(() => { root.scrollIntoView({ block: "start", behavior: "auto" }); }); } } return; } if (expanded) { // 收起:高度动画;若头部在视口外,滚动跟随钉住「收起」按钮 const from = host.scrollHeight; const to = collapsedPx ?? measureCollapsedPx(host, foldLines); const headerTop = root?.getBoundingClientRect().top ?? 0; const headerOut = headerTop < HEADER_PAD_PX; const foldEl = foldBtnRef.current; const pinTop = foldEl?.getBoundingClientRect().top; setAnimMaxPx(from); requestAnimationFrame(() => { requestAnimationFrame(() => { setExpanded(false); setAnimMaxPx(to); if (headerOut && foldEl != null && pinTop != null) { startScrollFollow(pinTop, foldEl); } animTimerRef.current = window.setTimeout(() => { stopScrollFollow(); setAnimMaxPx(null); animTimerRef.current = null; }, FOLD_ANIM_MS + 40); }); }); } else { // 展开:从预览高度拉到完整高度(不跟滚) const from = collapsedPx ?? host.getBoundingClientRect().height; setAnimMaxPx(from); setExpanded(true); requestAnimationFrame(() => { requestAnimationFrame(() => { setAnimMaxPx(host.scrollHeight); animTimerRef.current = window.setTimeout(() => { setAnimMaxPx(null); animTimerRef.current = null; }, FOLD_ANIM_MS + 40); }); }); } }; const preClass = ["md-code-pre", "shiki", className].filter(Boolean).join(" "); // 动画用像素 max-height;稳态折叠用缓存高度;不改动滚动位置 const bodyStyle = (() => { if (animMaxPx != null) { return { maxHeight: `${animMaxPx}px`, transition: `max-height ${FOLD_ANIM_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`, } as const; } if (!collapsed) return undefined; if (collapsedPx != null) return { maxHeight: `${collapsedPx}px` } as const; if (foldLines <= 0) return { maxHeight: "0px" } as const; return { maxHeight: `calc(1.7rem + ${foldLines} * 1.21em)`, } as const; })(); const showFade = collapsed || (animMaxPx != null && !expanded); return (
{language || "text"}
{clientHtml ? (
) : (
            {children}
          
)} {showFade && {canCollapse && ( )}
); }