/** * Process Flow Modal * * Displays a Mermaid flowchart for a process in a centered modal popup. */ import { useEffect, useRef, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Copy, Focus, ZoomIn, ZoomOut } from '@/lib/lucide-icons'; import mermaid from 'mermaid'; import DOMPurify from 'dompurify'; import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator'; interface ProcessFlowModalProps { process: ProcessData | null; onClose: () => void; onFocusInGraph?: (nodeIds: string[], processId: string) => void; isFullScreen?: boolean; } // Initialize mermaid with cyan/purple theme matching GitNexus mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true, // Try to suppress if supported maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams theme: 'base', themeVariables: { primaryColor: '#1e293b', // node bg primaryTextColor: '#f1f5f9', primaryBorderColor: '#22d3ee', lineColor: '#94a3b8', secondaryColor: '#1e293b', tertiaryColor: '#0f172a', mainBkg: '#1e293b', // background nodeBorder: '#22d3ee', clusterBkg: '#1e293b', clusterBorder: '#475569', titleColor: '#f1f5f9', edgeLabelBackground: '#0f172a', }, flowchart: { curve: 'basis', padding: 50, nodeSpacing: 120, rankSpacing: 140, htmlLabels: true, }, }); // Suppress distinct syntax error overlay mermaid.parseError = (err) => { // Suppress visual error - we handle errors in the render try/catch console.debug('Mermaid parse error (suppressed):', err); }; export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false, }: ProcessFlowModalProps) => { const { t } = useTranslation(['graph', 'common']); const containerRef = useRef(null); const diagramRef = useRef(null); const scrollContainerRef = useRef(null); // Full process map gets higher default zoom (667%) and max zoom (3000%) const defaultZoom = isFullScreen ? 6.67 : 1; const maxZoom = isFullScreen ? 30 : 10; const [zoom, setZoom] = useState(defaultZoom); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); // Reset zoom when switching between full screen and regular mode useEffect(() => { setZoom(defaultZoom); setPan({ x: 0, y: 0 }); }, [isFullScreen, defaultZoom]); // Handle zoom with scroll wheel useEffect(() => { const handleWheel = (e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY * -0.001; setZoom((prev) => Math.min(Math.max(0.1, prev + delta), maxZoom)); }; const container = scrollContainerRef.current; if (container) { container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); } }, [process, maxZoom]); // Re-attach when process or maxZoom changes // Handle keyboard zoom useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === '+' || e.key === '=') { setZoom((prev) => Math.min(prev + 0.2, maxZoom)); } else if (e.key === '-' || e.key === '_') { setZoom((prev) => Math.max(prev - 0.2, 0.1)); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [maxZoom]); // Zoom in/out handlers const handleZoomIn = useCallback(() => { setZoom((prev) => Math.min(prev + 0.25, maxZoom)); }, [maxZoom]); const handleZoomOut = useCallback(() => { setZoom((prev) => Math.max(prev - 0.25, 0.1)); }, []); // Handle pan with mouse drag const handleMouseDown = useCallback( (e: React.MouseEvent) => { setIsPanning(true); setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); }, [pan], ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!isPanning) return; setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); }, [isPanning, panStart], ); const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); const resetView = useCallback(() => { setZoom(defaultZoom); setPan({ x: 0, y: 0 }); }, [defaultZoom]); // Render mermaid diagram useEffect(() => { if (!process || !diagramRef.current) return; const renderDiagram = async () => { try { // Check if we have raw mermaid code (from AI chat) or need to generate it const mermaidCode = process.rawMermaid ? process.rawMermaid : generateProcessMermaid(process); const id = `mermaid-${Date.now()}`; // Clear previous content diagramRef.current!.innerHTML = ''; const { svg } = await mermaid.render(id, mermaidCode); if (!diagramRef.current) return; diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'], }); } catch (error) { console.error('Mermaid render error:', error); const errorMessage = error instanceof Error ? error.message : String(error); const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded'); diagramRef.current!.innerHTML = `
${isSizeError ? t('graph:processFlow.diagramTooLarge') : t('graph:processFlow.renderError')}
${ isSizeError ? t('graph:processFlow.tooComplex', { count: process.steps?.length || 0 }) : t('graph:processFlow.unableToRender', { count: process.steps?.length || 0 }) }
`; } }; renderDiagram(); }, [process, t]); // Close on escape useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [onClose]); // Close on backdrop click const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === containerRef.current) { onClose(); } }, [onClose], ); // Copy mermaid code to clipboard const handleCopyMermaid = useCallback(async () => { if (!process) return; const mermaidCode = generateProcessMermaid(process); await navigator.clipboard.writeText(mermaidCode); }, [process]); // Focus in graph const handleFocusInGraph = useCallback(() => { if (!process || !onFocusInGraph) return; const nodeIds = process.steps.map((s) => s.id); onFocusInGraph(nodeIds, process.id); onClose(); }, [process, onFocusInGraph, onClose]); if (!process) return null; return (
{/* Glassmorphism Modal */}
{/* Subtle gradient overlay for extra glass feel */}
{/* Header */}

{t('graph:processFlow.title', { label: process.label })}

{/* Diagram */}
{/* Footer Actions */}
{/* Zoom controls */}
{Math.round(zoom * 100)}%
{onFocusInGraph && ( )}
); };