/ src / components / chat / code-block.tsx
code-block.tsx
  1  'use client'
  2  
  3  import { useCallback, useState, type ReactNode } from 'react'
  4  import { copyTextToClipboard } from '@/lib/clipboard'
  5  
  6  function extractText(node: ReactNode): string {
  7    if (typeof node === 'string') return node
  8    if (typeof node === 'number') return String(node)
  9    if (!node) return ''
 10    if (Array.isArray(node)) return node.map(extractText).join('')
 11    if (typeof node === 'object' && 'props' in node) {
 12      return extractText((node as any).props.children)
 13    }
 14    return ''
 15  }
 16  
 17  interface Props {
 18    children: ReactNode
 19    className?: string
 20  }
 21  
 22  const PREVIEWABLE = new Set(['html', 'htm', 'svg'])
 23  
 24  export function CodeBlock({ children, className }: Props) {
 25    const [copied, setCopied] = useState(false)
 26    const [previewing, setPreviewing] = useState(false)
 27    const language = className?.replace(/hljs\s*/g, '').replace(/language-/g, '').trim() || ''
 28    const canPreview = PREVIEWABLE.has(language)
 29  
 30    const getText = useCallback(() => extractText(children), [children])
 31  
 32    const handleCopy = useCallback(() => {
 33      void copyTextToClipboard(getText()).then((copiedText) => {
 34        if (!copiedText) return
 35        setCopied(true)
 36        setTimeout(() => setCopied(false), 2000)
 37      })
 38    }, [getText])
 39  
 40    const handlePreview = useCallback(() => {
 41      setPreviewing((v) => !v)
 42    }, [])
 43  
 44    const handleOpenTab = useCallback(() => {
 45      const text = getText()
 46      const blob = new Blob([text], { type: language === 'svg' ? 'image/svg+xml' : 'text/html' })
 47      window.open(URL.createObjectURL(blob), '_blank')
 48    }, [getText, language])
 49  
 50    const handleSave = useCallback(() => {
 51      const text = getText()
 52      const ext = language || 'txt'
 53      const blob = new Blob([text], { type: 'text/plain' })
 54      const a = document.createElement('a')
 55      a.href = URL.createObjectURL(blob)
 56      a.download = `code.${ext}`
 57      a.click()
 58    }, [getText, language])
 59  
 60    return (
 61      <div className="relative group/code command-surface">
 62        <div className="flex items-center justify-between px-4 py-2 bg-black/30 border-b border-white/[0.03]">
 63          <span className="text-[10px] font-600 uppercase tracking-[0.08em] text-text-3 font-mono">{language}</span>
 64          <div className="flex items-center gap-1">
 65            {canPreview && (
 66              <>
 67                <button
 68                  onClick={handlePreview}
 69                  className={`flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
 70                    transition-all duration-200 px-2 py-0.5 rounded-[6px]
 71                    ${previewing ? 'text-accent-bright' : 'text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]'}`}
 72                  style={{ fontFamily: 'inherit' }}
 73                >
 74                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
 75                    <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
 76                    <circle cx="12" cy="12" r="3" />
 77                  </svg>
 78                  {previewing ? 'Code' : 'Preview'}
 79                </button>
 80                <button
 81                  onClick={handleOpenTab}
 82                  className="flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
 83                    transition-all duration-200 px-2 py-0.5 rounded-[6px] text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]"
 84                  style={{ fontFamily: 'inherit' }}
 85                  title="Open in new tab"
 86                >
 87                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
 88                    <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
 89                    <polyline points="15 3 21 3 21 9" />
 90                    <line x1="10" y1="14" x2="21" y2="3" />
 91                  </svg>
 92                  Open
 93                </button>
 94              </>
 95            )}
 96            <button
 97              onClick={handleSave}
 98              className="flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
 99                transition-all duration-200 px-2 py-0.5 rounded-[6px] text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]"
100              style={{ fontFamily: 'inherit' }}
101            >
102              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
103                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
104                <polyline points="7 10 12 15 17 10" />
105                <line x1="12" y1="15" x2="12" y2="3" />
106              </svg>
107              Save
108            </button>
109            <button
110              onClick={handleCopy}
111              className={`flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
112                transition-all duration-200 px-2 py-0.5 rounded-[6px]
113                ${copied
114                  ? 'text-success'
115                  : 'text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]'}`}
116              style={{ fontFamily: 'inherit' }}
117            >
118              {copied ? (
119                <>
120                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg>
121                  Copied
122                </>
123              ) : (
124                <>
125                  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
126                    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
127                    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
128                  </svg>
129                  Copy
130                </>
131              )}
132            </button>
133          </div>
134        </div>
135        {canPreview && previewing ? (
136          <iframe
137            srcDoc={getText()}
138            sandbox="allow-scripts"
139            className="w-full border-none bg-white rounded-b-[8px]"
140            style={{ minHeight: 300, maxHeight: 600 }}
141            title="Code preview"
142          />
143        ) : (
144          <code className={className}>{children}</code>
145        )}
146      </div>
147    )
148  }