feat: add copy button on code blocks
Hover over any fenced code block to reveal a floating copy-to-clipboard button (top-right corner). Provides visual feedback (checkmark) on success. Uses a custom ReactMarkdown <pre> component wrapper.
This commit is contained in:
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { ToolCall } from './ToolCall';
|
||||
import { Bot, User, Wrench } from 'lucide-react';
|
||||
import { t, locale } from '../lib/i18n';
|
||||
@@ -127,10 +128,12 @@ function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
|
||||
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
|
||||
}
|
||||
|
||||
const markdownComponents = { pre: CodeBlock };
|
||||
|
||||
function renderTextBlocks(blocks: MessageBlock[]) {
|
||||
return getTextBlocks(blocks).map((block, i) => (
|
||||
<div key={`text-${i}`} className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
||||
{autoFormatText((block as any).text)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -220,7 +223,7 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
|
||||
{/* User-visible text */}
|
||||
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
||||
{autoFormatText(message.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
38
src/components/CodeBlock.tsx
Normal file
38
src/components/CodeBlock.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useCallback, type HTMLAttributes } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Custom <pre> renderer for ReactMarkdown.
|
||||
* Wraps code blocks with a floating copy button.
|
||||
*/
|
||||
export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
// Extract text from the nested <code> element
|
||||
const code = (props.children as any)?.props?.children;
|
||||
if (typeof code === 'string') {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
}, [props.children]);
|
||||
|
||||
return (
|
||||
<div className="group/code relative">
|
||||
<pre {...props} />
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-lg bg-zinc-700/60 hover:bg-zinc-600/80 border border-white/10 text-zinc-400 hover:text-zinc-200 opacity-0 group-hover/code:opacity-100 transition-opacity duration-150"
|
||||
title="Copy code"
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? <Check className="h-3.5 w-3.5 text-green-400" />
|
||||
: <Copy className="h-3.5 w-3.5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user