Files
app-261004/components/mdx/code-block.tsx
T
2026-10-08 11:09:37 +08:00

120 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 代码块容器:对应 DESIGN.md 的 code-block + code-block-header + copy-code-button。
*
* Shiki 已在构建期把高亮结果编译成 <pre><code>…</code></pre>,
* 这里只负责套上头部(语言标签 + 复制按钮),不重新解析代码。
*
* 复制逻辑优先使用 Clipboard API,失败时回退到 execCommand,
* 以便在非 HTTPS 环境下依然可用。
*/
export interface CodeBlockProps {
children?: ReactNode;
className?: string;
/** Shiki 输出的 data-language(由 rehype-pretty-code 注入到 figure 上) */
"data-language"?: string;
raw?: string;
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 落回下面的兼容实现
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand("copy");
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
/** 从 <pre> 的 React 子节点中抽取纯文本,作为复制内容 */
function extractText(node: ReactNode): string {
if (node === null || node === undefined || 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 props = (node as { props?: { children?: ReactNode } }).props;
return extractText(props?.children);
}
return "";
}
export function CodeBlock({ children, className, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const language =
props["data-language"] ?? detectLanguage(className) ?? "text";
const handleCopy = async () => {
const text = extractText(children).replace(/\n$/, "");
const succeeded = await copyText(text);
if (succeeded) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="group relative">
<div className="absolute right-xs top-xs z-10 flex items-center gap-xs">
<span className="font-mono text-caption text-on-dark-muted">
{language}
</span>
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "已复制代码" : "复制代码"}
className="inline-flex items-center gap-[4px] rounded-sm border border-hairline-dark bg-surface-code/90 px-xs py-[4px] font-mono text-caption text-on-dark-muted transition-colors duration-150 active:text-on-dark"
>
{copied ? (
<Check className="size-3 text-brand-green" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
<span className={cn(copied && "text-brand-green")}>
{copied ? "已复制" : "复制"}
</span>
</button>
</div>
<pre className={className} {...props}>
{children}
</pre>
</div>
);
}
/** 从 className 中提取语言,如 "language-ts" → "ts" */
function detectLanguage(className?: string): string | null {
const match = /language-([\w-]+)/.exec(className ?? "");
return match ? match[1] : null;
}