import { useEffect, useRef, useCallback, type CSSProperties } from 'react' import BpmnViewer from 'bpmn-js/lib/Viewer' import { Maximize2, ZoomIn, ZoomOut } from 'lucide-react' const ZOOM_STEP = 1.2 const ZOOM_MIN = 0.1 const ZOOM_MAX = 4 export type BpmnElementCategory = 'activity' | 'gateway' | 'other' const ACTIVITY_TYPES = new Set([ 'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask', 'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess', ]) const GATEWAY_TYPES = new Set([ 'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway', 'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway', ]) function classifyElement(type: string): BpmnElementCategory { if (ACTIVITY_TYPES.has(type)) return 'activity' if (GATEWAY_TYPES.has(type)) return 'gateway' return 'other' } // Badge de automatización — identidad InQ naranja sólido (Etapa 9 Sprint 1.5). // Usa var(--inq-orange) definido en globals.css :root para consistencia con el sistema de design. // Emoji ⚡ como símbolo provisional — reemplazo por agendado Sprint 2-3. function makeBadgeHtml(): string { return `
` } // Identificador del tipo de overlay para poder remover solo los nuestros. const OVERLAY_TYPE = 'automation-indicator' interface BpmnCanvasProps { xml: string onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void selectedElementId?: string | null automatableElementIds?: string[] // IDs de actividades marcadas como automatizables className?: string } export function BpmnCanvas({ xml, onElementClick, selectedElementId, automatableElementIds = [], className, }: BpmnCanvasProps) { const containerRef = useRef(null) const viewerRef = useRef | null>(null) const isImportedRef = useRef(false) // ─── Inicializar viewer ──────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return const viewer = new BpmnViewer({ container: containerRef.current }) viewerRef.current = viewer return () => { isImportedRef.current = false viewer.destroy() viewerRef.current = null } }, []) // ─── Sincronizar overlays de automatización ─────────────────────────────── const syncAutomationOverlays = useCallback(() => { const viewer = viewerRef.current if (!viewer || !isImportedRef.current) return const overlays = viewer.get('overlays') as any const reg = viewer.get('elementRegistry') as any // Remover overlays anteriores del tipo propio antes de re-agregar overlays.remove({ type: OVERLAY_TYPE }) for (const elementId of automatableElementIds) { try { // Leer el ancho real del elemento para posicionar el badge en la // esquina superior derecha con overlap: left = width - 12 pone el // borde derecho del badge 8px fuera del borde derecho del nodo. // (right: -8 en la API tiene semántica distinta: left = -right + width // con -8 daría left = 8 + width, enteramente fuera del nodo.) const el = reg?.get(elementId) const width = el?.width ?? 100 overlays.add(elementId, OVERLAY_TYPE, { position: { top: -8, left: width - 12 }, html: makeBadgeHtml(), }) } catch { // El elemento puede no existir en el diagrama actual — ignorar } } }, [automatableElementIds]) // ─── Importar XML ────────────────────────────────────────────────────────── useEffect(() => { const viewer = viewerRef.current if (!viewer || !xml) return isImportedRef.current = false viewer.importXML(xml).then(({ warnings }) => { // Si el viewer fue destruido y reemplazado (p.ej. React Strict Mode), ignorar if (viewerRef.current !== viewer) return if (warnings.length) console.warn('bpmn-js warnings:', warnings) const canvas = viewer.get('canvas') as any canvas.zoom('fit-viewport', 'auto') isImportedRef.current = true syncAutomationOverlays() }).catch((err: Error) => { if (viewerRef.current !== viewer) return console.error('Error cargando BPMN:', err) }) }, [xml, syncAutomationOverlays]) // ─── Re-sincronizar overlays cuando cambia la lista de automatizables ───── useEffect(() => { syncAutomationOverlays() }, [syncAutomationOverlays]) // ─── Click handler ───────────────────────────────────────────────────────── useEffect(() => { const viewer = viewerRef.current if (!viewer || !onElementClick) return const eventBus = viewer.get('eventBus') as any const handler = (event: any) => { const el = event.element if (!el) return const category = classifyElement(el.type) if (category === 'other') return onElementClick(el.id, el.businessObject?.name ?? el.id, category) } eventBus.on('element.click', handler) return () => eventBus.off('element.click', handler) }, [onElementClick]) // ─── Highlight del elemento seleccionado ────────────────────────────────── useEffect(() => { const viewer = viewerRef.current if (!viewer) return const canvas = viewer.get('canvas') as any const reg = viewer.get('elementRegistry') as any reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected')) if (selectedElementId) { try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ } } }, [selectedElementId]) function handleZoomIn() { const canvas = viewerRef.current?.get('canvas') as any if (!canvas) return canvas.zoom(Math.min(canvas.zoom() * ZOOM_STEP, ZOOM_MAX)) } function handleZoomOut() { const canvas = viewerRef.current?.get('canvas') as any if (!canvas) return canvas.zoom(Math.max(canvas.zoom() / ZOOM_STEP, ZOOM_MIN)) } const controlButtonStyle: CSSProperties = { width: 32, height: 32, borderRadius: 6, background: 'hsl(var(--background))', border: '1px solid hsl(var(--border))', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', } return (
{/* Encuadrar — esquina superior derecha */} {/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
) }