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 */}
)
}