mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(ui): Add copy button for code blocks in mothership (#4033)
* Add copy button for code blocks in mothership * Move to shared copy code button * Handle react node case for copy * fix(copy-button): address PR review feedback - Await clipboard write and clear timeout on unmount in CopyCodeButton - Fix hover bg color matching container bg (surface-4 -> surface-5) - Extract extractTextContent to shared util at lib/core/utils/react-node-text.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix lint --------- Co-authored-by: Theodore Li <theo@sim.ai> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { CopyCodeButton } from '@/components/ui/copy-code-button'
|
||||
import { extractTextContent } from '@/lib/core/utils/react-node-text'
|
||||
|
||||
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -102,6 +104,10 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
<span className='font-sans text-gray-400 text-xs'>
|
||||
{codeProps.className?.replace('language-', '') || 'code'}
|
||||
</span>
|
||||
<CopyCodeButton
|
||||
code={extractTextContent(codeContent)}
|
||||
className='text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
||||
/>
|
||||
</div>
|
||||
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
|
||||
{codeContent}
|
||||
|
||||
@@ -9,7 +9,9 @@ import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
import { Checkbox, highlight, languages } from '@/components/emcn'
|
||||
import { CopyCodeButton } from '@/components/ui/copy-code-button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractTextContent } from '@/lib/core/utils/react-node-text'
|
||||
import {
|
||||
PendingTagIndicator,
|
||||
parseSpecialTags,
|
||||
@@ -33,16 +35,6 @@ const LANG_ALIASES: Record<string, string> = {
|
||||
py: 'python',
|
||||
}
|
||||
|
||||
function extractTextContent(node: React.ReactNode): string {
|
||||
if (typeof node === 'string') return node
|
||||
if (typeof node === 'number') return String(node)
|
||||
if (!node) return ''
|
||||
if (Array.isArray(node)) return node.map(extractTextContent).join('')
|
||||
if (isValidElement(node))
|
||||
return extractTextContent((node.props as { children?: React.ReactNode }).children)
|
||||
return ''
|
||||
}
|
||||
|
||||
const PROSE_CLASSES = cn(
|
||||
'prose prose-base dark:prose-invert max-w-none',
|
||||
'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]',
|
||||
@@ -125,11 +117,13 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
|
||||
|
||||
return (
|
||||
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
|
||||
{language && (
|
||||
<div className='border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 text-[var(--text-tertiary)] text-xs dark:bg-[var(--surface-4)]'>
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
|
||||
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
|
||||
<CopyCodeButton
|
||||
code={codeString}
|
||||
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
|
||||
<pre
|
||||
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px]'
|
||||
|
||||
42
apps/sim/components/ui/copy-code-button.tsx
Normal file
42
apps/sim/components/ui/copy-code-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface CopyCodeButtonProps {
|
||||
code: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
}, [code])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
14
apps/sim/lib/core/utils/react-node-text.ts
Normal file
14
apps/sim/lib/core/utils/react-node-text.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { isValidElement, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Recursively extracts plain text content from a React node tree.
|
||||
*/
|
||||
export function extractTextContent(node: ReactNode): string {
|
||||
if (typeof node === 'string') return node
|
||||
if (typeof node === 'number') return String(node)
|
||||
if (!node) return ''
|
||||
if (Array.isArray(node)) return node.map(extractTextContent).join('')
|
||||
if (isValidElement(node))
|
||||
return extractTextContent((node.props as { children?: ReactNode }).children)
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user