Spaces:
Paused
Paused
| /** | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β MERMAID DIAGRAM COMPONENT β | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β Renders Mermaid diagrams from DSL syntax β | |
| * β Part of the Visual Cortex Layer β | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| */ | |
| import { useEffect, useRef, useState, useCallback } from 'react'; | |
| import { cn } from '@/lib/utils'; | |
| import { Download, Copy, ZoomIn, ZoomOut, Maximize2, RefreshCw } from 'lucide-react'; | |
| export interface MermaidDiagramProps { | |
| /** Mermaid DSL syntax */ | |
| code: string; | |
| /** Diagram title (optional) */ | |
| title?: string; | |
| /** Theme: dark, default, forest, neutral */ | |
| theme?: 'dark' | 'default' | 'forest' | 'neutral'; | |
| /** Background color */ | |
| backgroundColor?: string; | |
| /** Enable zoom controls */ | |
| zoomable?: boolean; | |
| /** Custom class */ | |
| className?: string; | |
| /** Callback when diagram is rendered */ | |
| onRender?: (svg: string) => void; | |
| /** Callback on error */ | |
| onError?: (error: Error) => void; | |
| } | |
| export function MermaidDiagram({ | |
| code, | |
| title, | |
| theme = 'dark', | |
| backgroundColor = 'transparent', | |
| zoomable = true, | |
| className, | |
| onRender, | |
| onError, | |
| }: MermaidDiagramProps) { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [svg, setSvg] = useState<string>(''); | |
| const [error, setError] = useState<string | null>(null); | |
| const [zoom, setZoom] = useState(1); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [mermaidLoaded, setMermaidLoaded] = useState(false); | |
| // Dynamically load mermaid | |
| useEffect(() => { | |
| const loadMermaid = async () => { | |
| try { | |
| const mermaid = (await import('mermaid')).default; | |
| mermaid.initialize({ | |
| startOnLoad: false, | |
| theme: theme, | |
| securityLevel: 'loose', | |
| fontFamily: 'JetBrains Mono, monospace', | |
| flowchart: { | |
| useMaxWidth: true, | |
| htmlLabels: true, | |
| curve: 'basis', | |
| }, | |
| sequence: { | |
| useMaxWidth: true, | |
| diagramMarginX: 50, | |
| diagramMarginY: 10, | |
| }, | |
| themeVariables: theme === 'dark' ? { | |
| primaryColor: '#6366f1', | |
| primaryTextColor: '#e2e8f0', | |
| primaryBorderColor: '#4f46e5', | |
| lineColor: '#64748b', | |
| secondaryColor: '#1e293b', | |
| tertiaryColor: '#0f172a', | |
| background: '#0f172a', | |
| mainBkg: '#1e293b', | |
| nodeBorder: '#4f46e5', | |
| clusterBkg: '#1e293b', | |
| clusterBorder: '#334155', | |
| titleColor: '#f8fafc', | |
| edgeLabelBackground: '#1e293b', | |
| } : undefined, | |
| }); | |
| setMermaidLoaded(true); | |
| } catch (err) { | |
| console.error('Failed to load mermaid:', err); | |
| setError('Failed to load diagram library'); | |
| setIsLoading(false); | |
| } | |
| }; | |
| loadMermaid(); | |
| }, [theme]); | |
| // Render diagram when code changes | |
| useEffect(() => { | |
| if (!mermaidLoaded || !code) return; | |
| const renderDiagram = async () => { | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const mermaid = (await import('mermaid')).default; | |
| const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |
| // Validate and render | |
| const { svg: renderedSvg } = await mermaid.render(id, code); | |
| setSvg(renderedSvg); | |
| onRender?.(renderedSvg); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : 'Failed to render diagram'; | |
| setError(errorMessage); | |
| onError?.(err instanceof Error ? err : new Error(errorMessage)); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| renderDiagram(); | |
| }, [code, mermaidLoaded, onRender, onError]); | |
| // Copy SVG to clipboard | |
| const handleCopy = useCallback(async () => { | |
| if (svg) { | |
| await navigator.clipboard.writeText(svg); | |
| } | |
| }, [svg]); | |
| // Download as SVG | |
| const handleDownload = useCallback(() => { | |
| if (!svg) return; | |
| const blob = new Blob([svg], { type: 'image/svg+xml' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${title || 'diagram'}.svg`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| }, [svg, title]); | |
| // Zoom controls | |
| const handleZoomIn = () => setZoom(z => Math.min(z + 0.25, 3)); | |
| const handleZoomOut = () => setZoom(z => Math.max(z - 0.25, 0.25)); | |
| const handleReset = () => setZoom(1); | |
| return ( | |
| <div className={cn( | |
| 'mermaid-diagram rounded-lg border border-border/50 overflow-hidden', | |
| className | |
| )}> | |
| {/* Header */} | |
| {(title || zoomable) && ( | |
| <div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border/30"> | |
| {title && ( | |
| <span className="text-sm font-medium text-foreground/80">{title}</span> | |
| )} | |
| <div className="flex items-center gap-1"> | |
| {zoomable && ( | |
| <> | |
| <button | |
| onClick={handleZoomOut} | |
| className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Zoom out" | |
| > | |
| <ZoomOut className="w-4 h-4" /> | |
| </button> | |
| <span className="text-xs text-muted-foreground min-w-[3rem] text-center"> | |
| {Math.round(zoom * 100)}% | |
| </span> | |
| <button | |
| onClick={handleZoomIn} | |
| className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Zoom in" | |
| > | |
| <ZoomIn className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={handleReset} | |
| className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Reset zoom" | |
| > | |
| <Maximize2 className="w-4 h-4" /> | |
| </button> | |
| <div className="w-px h-4 bg-border/50 mx-1" /> | |
| </> | |
| )} | |
| <button | |
| onClick={handleCopy} | |
| className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Copy SVG" | |
| disabled={!svg} | |
| > | |
| <Copy className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={handleDownload} | |
| className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors" | |
| title="Download SVG" | |
| disabled={!svg} | |
| > | |
| <Download className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Diagram Container */} | |
| <div | |
| ref={containerRef} | |
| className="relative overflow-auto p-4" | |
| style={{ backgroundColor }} | |
| > | |
| {isLoading && ( | |
| <div className="flex items-center justify-center py-8"> | |
| <RefreshCw className="w-6 h-6 animate-spin text-primary" /> | |
| <span className="ml-2 text-sm text-muted-foreground">Rendering diagram...</span> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="p-4 rounded bg-red-500/10 border border-red-500/30 text-red-400"> | |
| <p className="text-sm font-medium">Diagram Error</p> | |
| <p className="text-xs mt-1 opacity-80">{error}</p> | |
| <pre className="mt-2 text-xs bg-black/20 p-2 rounded overflow-auto max-h-32"> | |
| {code} | |
| </pre> | |
| </div> | |
| )} | |
| {!isLoading && !error && svg && ( | |
| <div | |
| className="mermaid-svg-container transition-transform duration-200 ease-out" | |
| style={{ | |
| transform: `scale(${zoom})`, | |
| transformOrigin: 'top left', | |
| }} | |
| dangerouslySetInnerHTML={{ __html: svg }} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default MermaidDiagram; | |