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 }