Skip to content

Commit f966b0f

Browse files
authored
feat: now you can copy a codeblock (#101)
Now you can copy a code block in a bot message. ![CleanShot 2025-07-04 at 22 41 57](https://github.com/user-attachments/assets/1b45c50c-c5e9-4939-855b-af4beae94642) Closes #100
1 parent d7c5cc4 commit f966b0f

File tree

2 files changed

+77
-4
lines changed

2 files changed

+77
-4
lines changed

src/components/CodeBlock.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useRef, type ComponentProps } from 'react'
2+
import { Copy } from 'lucide-react'
3+
import { Button } from '@/components/ui/button'
4+
import { copyToClipboard } from '@/lib/utils/clipboard'
5+
6+
type CodeBlockProps = {
7+
onCopySuccess?: (text: string) => void
8+
onCopyError?: (text: string, error?: unknown) => void
9+
copyButtonLabel?: string
10+
copiedButtonLabel?: string
11+
} & Omit<ComponentProps<'pre'>, 'onCopy'>
12+
13+
export function CodeBlock({
14+
children,
15+
className,
16+
onCopySuccess,
17+
onCopyError,
18+
...props
19+
}: CodeBlockProps) {
20+
const preRef = useRef<HTMLPreElement>(null)
21+
22+
const handleCopy = async () => {
23+
// Extract text content from the actual DOM element
24+
const textContent = preRef.current?.textContent ?? ''
25+
26+
try {
27+
const success = await copyToClipboard(textContent)
28+
29+
if (success) {
30+
onCopySuccess?.(textContent)
31+
} else {
32+
onCopyError?.(textContent)
33+
}
34+
} catch (error) {
35+
onCopyError?.(textContent, error)
36+
}
37+
}
38+
39+
return (
40+
<div className="relative group">
41+
<pre
42+
ref={preRef}
43+
className={`overflow-x-auto whitespace-pre-wrap break-words ${className || ''}`}
44+
{...props}
45+
>
46+
{children}
47+
</pre>
48+
<Button
49+
onClick={handleCopy}
50+
variant="secondary"
51+
size="sm"
52+
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-xs"
53+
>
54+
<Copy className="h-4 w-4" />
55+
<span className="sr-only">Copy code snippet</span>
56+
</Button>
57+
</div>
58+
)
59+
}

src/components/MarkdownContent.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
import ReactMarkdown from 'react-markdown'
22
import remarkGfm from 'remark-gfm'
3+
import { toast } from 'sonner'
4+
import { CodeBlock } from '@/components/CodeBlock'
35

46
type MarkdownContentProps = {
57
content: string
68
}
79

810
export function MarkdownContent({ content }: MarkdownContentProps) {
11+
const handleCopySuccess = () => {
12+
toast.success('Copied code snippet to clipboard')
13+
}
14+
15+
const handleCopyError = () => {
16+
toast.error('Failed to copy code snippet')
17+
}
18+
919
return (
1020
<div className="prose prose-sm dark:prose-invert max-w-none break-words [&>*+*]:mt-4 [&>h1+*]:mt-3 [&>h2+*]:mt-3 [&>h3+*]:mt-3 [&>p+ul]:mt-3 [&>p+ol]:mt-3">
1121
<ReactMarkdown
1222
remarkPlugins={[remarkGfm]}
1323
components={{
14-
pre: ({ node, ...props }) => (
15-
<pre
16-
className="overflow-x-auto whitespace-pre-wrap break-words"
24+
pre: ({ node, className, children, ...props }) => (
25+
<CodeBlock
26+
className={className}
27+
onCopySuccess={handleCopySuccess}
28+
onCopyError={handleCopyError}
1729
{...props}
18-
/>
30+
>
31+
{children}
32+
</CodeBlock>
1933
),
2034
code: ({ node, ...props }) => (
2135
<code className="break-words whitespace-pre-wrap" {...props} />

0 commit comments

Comments
 (0)