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 rehypeHighlight from 'rehype-highlight';
|
||||||
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
||||||
import { ThinkingBlock } from './ThinkingBlock';
|
import { ThinkingBlock } from './ThinkingBlock';
|
||||||
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { Bot, User, Wrench } from 'lucide-react';
|
import { Bot, User, Wrench } from 'lucide-react';
|
||||||
import { t, locale } from '../lib/i18n';
|
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');
|
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownComponents = { pre: CodeBlock };
|
||||||
|
|
||||||
function renderTextBlocks(blocks: MessageBlock[]) {
|
function renderTextBlocks(blocks: MessageBlock[]) {
|
||||||
return getTextBlocks(blocks).map((block, i) => (
|
return getTextBlocks(blocks).map((block, i) => (
|
||||||
<div key={`text-${i}`} className="markdown-body">
|
<div key={`text-${i}`} className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
||||||
{autoFormatText((block as any).text)}
|
{autoFormatText((block as any).text)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +223,7 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
|
|||||||
{/* User-visible text */}
|
{/* User-visible text */}
|
||||||
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
|
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
|
||||||
<div className="markdown-body">
|
<div className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
||||||
{autoFormatText(message.content)}
|
{autoFormatText(message.content)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</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