Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]',
Expand Down Expand Up @@ -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]'
Expand Down
42 changes: 42 additions & 0 deletions apps/sim/components/ui/copy-code-button.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions apps/sim/lib/core/utils/react-node-text.ts
Original file line number Diff line number Diff line change
@@ -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 ''
}
Loading