120 lines
3.5 KiB
TypeScript
120 lines
3.5 KiB
TypeScript
"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;
|
||
}
|