避免客户端覆盖 SSR 高亮;空行撑高;收起后 scrollIntoView 到代码块头部。 Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
7.4 KiB
TypeScript
239 lines
7.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|