164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react'
|
||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||
|
||
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'
|
||
}
|
||
|
||
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
|
||
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
|
||
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 8V4H8"/>
|
||
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
||
<path d="M2 14h2"/>
|
||
<path d="M20 14h2"/>
|
||
<path d="M15 13v2"/>
|
||
<path d="M9 13v2"/>
|
||
</svg>`
|
||
|
||
// HTML del badge de automatización.
|
||
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
|
||
// contrasta con el fondo blanco de los nodos BPMN.
|
||
function makeBadgeHtml(): string {
|
||
return `<div title="Actividad marcada como automatizable" style="
|
||
width:20px;height:20px;
|
||
border-radius:50%;
|
||
background:#fffbeb;
|
||
border:1px solid #e2e8f0;
|
||
box-shadow:0 1px 3px rgba(0,0,0,0.12);
|
||
display:flex;align-items:center;justify-content:center;
|
||
cursor:default;
|
||
pointer-events:none;
|
||
">${BOT_SVG}</div>`
|
||
}
|
||
|
||
// 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<HTMLDivElement>(null)
|
||
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | 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 }) => {
|
||
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) => 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])
|
||
|
||
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
|
||
}
|