88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
import type { ReactNode } from "react";
|
|
|
|
import { CopyOutlined } from "@ant-design/icons";
|
|
import { App, Button } from "antd";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { codeToHtml } from "shiki";
|
|
|
|
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
|
|
|
interface CodeBlockProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
isStreaming: boolean;
|
|
}
|
|
|
|
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
|
|
const { message } = App.useApp();
|
|
const isDark = useIsDark();
|
|
const [highlighted, setHighlighted] = useState<null | string>(null);
|
|
const { codeText, lang } = extractCode(children);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
navigator.clipboard.writeText(codeText).then(
|
|
() => message.success("已复制"),
|
|
() => message.error("复制失败"),
|
|
);
|
|
}, [codeText, message]);
|
|
|
|
useEffect(() => {
|
|
if (isStreaming || !codeText) return;
|
|
|
|
let cancelled = false;
|
|
codeToHtml(codeText, {
|
|
lang,
|
|
theme: isDark ? "github-dark" : "github-light",
|
|
})
|
|
.then((html) => {
|
|
if (!cancelled) setHighlighted(html);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setHighlighted(null);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [codeText, lang, isDark, isStreaming]);
|
|
|
|
if (isStreaming) {
|
|
return (
|
|
<pre className="code-block">
|
|
<code>{codeText}</code>
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="code-block">
|
|
<div className="code-block-header">
|
|
<span className="code-block-lang">{lang}</span>
|
|
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
|
</div>
|
|
{highlighted ? (
|
|
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
) : (
|
|
<div className="code-block-body">
|
|
<pre>
|
|
<code>{codeText}</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function extractCode(children: ReactNode): { codeText: string; lang: string } {
|
|
if (children && typeof children === "object" && "props" in children && children.props) {
|
|
const props = children.props as Record<string, unknown>;
|
|
const codeText = typeof props["children"] === "string" ? props["children"] : "";
|
|
const codeClassName = typeof props["className"] === "string" ? props["className"] : "";
|
|
const classes = codeClassName.split(/\s+/);
|
|
const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? "";
|
|
const lang = langClass.replace(/^(language|lang)-/, "") || "text";
|
|
return { codeText, lang };
|
|
}
|
|
return { codeText: typeof children === "string" ? children : "", lang: "text" };
|
|
}
|