nella parte dell'header dove c'è workflowDetail a ...
Créé le : 14 août 2025
Créé le : 14 août 2025
nella parte dell'header dove c'è workflowDetail a sinistra dobbiamo aggiungere una ricerca input, con una lista, ogni elemento della lista è un nodo , e cercando per name e description li filtra , se clicco su un elemnto deve fare zoom su quel specifico nodo 'use client' import { useTranslation } from '@/app/i18n/client' import { Graph, Markup, Node } from '@antv/x6' import { Card, Dialog, DialogBody, DialogHeader } from '@material-tailwind/react' import insertCss from 'insert-css' import React, { useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' import { NodeDetail } from './NodeDetail' const CustomNodeComponent = ({ node, onRemove }: { node: Node; onRemove: (id: string) => void }) => { const data = node.getData() const { description, color } = data || {} const containerRef = useRef<HTMLDivElement>(null) useEffect(() => { const updateSize = () => { if (containerRef.current) { const { offsetWidth, offsetHeight } = containerRef.current if (offsetWidth && offsetHeight) { node.resize(offsetWidth, offsetHeight) node.updatePorts() } } } const observer = new ResizeObserver(updateSize) if (containerRef.current) { observer.observe(containerRef.current) } return () => { observer.disconnect() } }, [node]) return ( <div ref={containerRef} style={{ backgroundColor: '#fff', border: 2px solid {color}, borderRadius: '12px', padding: '12px', fontFamily: 'Inter, sans-serif', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', boxSizing: 'border-box', minWidth: '180px', maxWidth: '300px', position: 'relative', cursor: 'move', }} > <button style={{ position: 'absolute', top: '8px', right: '8px', background: 'transparent', border: 'none', fontSize: '16px', fontWeight: 'bold', cursor: 'pointer', color: 'red', padding: '0', lineHeight: '1', zIndex: '10', }} onClick={(e) => { e.stopPropagation() onRemove(node.id) }} > × </button> <div style={{ fontSize: '14px', fontWeight: 600, color: '#111827', marginBottom: '4px', wordBreak: 'break-word', whiteSpace: 'normal', width: '100%', }} > {node.getLabel()} </div> <p style={{ fontSize: '13px', color: '#6b7280', margin: '6px 0 0', lineHeight: '1.4', wordBreak: 'break-word', whiteSpace: 'normal', width: '100%', overflowWrap: 'break-word', }} > {description} </p> </div> ) } const NodeTypes = [ { label: 'Email', type: 'email', description: 'Enviar email al lead (basado en template + datos lead)' }, { label: 'WhatsApp', type: 'whatsapp', description: 'Enviar mensaje WA (template, número, texto dinámico)' }, { label: 'Llamada', type: 'call', description: 'Encolar llamada (via API o Twilio Studio)' }, { label: 'Espera', type: 'delay', description: 'Espera X horas antes del siguiente bloque' }, { label: 'Lógica', type: 'logic', description: 'Evaluar condición y redirigir a uno u más paths posibles (como Zapier "Paths")', }, { label: 'Fin', type: 'end', description: 'Fin del workflow' }, ] Graph.registerNode( 'custom-react-node', { inherit: 'rect', width: 300, height: 100, markup: [ { tagName: 'rect', selector: 'background' }, { tagName: 'rect', selector: 'body' }, { tagName: 'text', selector: 'label' }, { tagName: 'text', selector: 'description' }, ], attrs: { background: { refWidth: '90%', refHeight: '60%', refX: '5%', refY: '20%', fill: '#f3f4f6', stroke: '#000000', strokeWidth: 2, rx: 8, ry: 8, }, body: { refWidth: '100%', refHeight: '100%', stroke: '#facc15', strokeWidth: 2, fill: 'white', rx: 12, ry: 12, }, label: { refX: '5%', refY: '30%', textAnchor: 'start', textVerticalAnchor: 'middle', fontSize: 14, fill: '#111827', fontWeight: 'bold', }, description: { refX: '5%', refY: '45%', textAnchor: 'start', textVerticalAnchor: 'top', fontSize: 12, fill: '#59626fdb', textWrap: { width: 260, lineHeight: 2, breakWord: true, }, lineHeight: '15px', }, }, }, true ) export function WorkflowDetail(props: { handleOpen: () => void; data: unknown }) { const { handleOpen } = props const { t } = useTranslation() const containerRef = useRef<HTMLDivElement | null>(null) const graphRef = useRef<Graph | null>(null) const [filter, setFilter] = useState('') const [nodesData, setNodesData] = useState<[]>(props.data.nodes) const [modalOpen, setModalOpen] = useState(false) const [selectedNode, setSelectedNode] = useState<Node | null>(null) const [formData, setFormData] = useState<Record<string, any>>({}) useEffect(() => { console.log('JSON aggiornato:', nodesData) if (graphRef && graphRef.current) console.log(graphRef.current.getNodes()) }, [nodesData]) useEffect(() => { if (graphRef && graphRef.current) console.log(graphRef.current.getNodes()) }, [graphRef]) const nodesDataRef = useRef(nodesData) useEffect(() => { nodesDataRef.current = nodesData }, [nodesData]) useEffect(() => { const initGraph = () => { if (!containerRef.current || graphRef.current) return // 🔷 Registra lo stile della connessione Graph.registerEdge( 'custom-edge', { inherit: 'edge', connector: { name: 'rounded' }, attrs: { line: { style: { animation: 'ant-line 30s infinite linear', }, stroke: '#0D496B', strokeWidth: 4, strokeDasharray: 5, strokeDashoffset: 0, targetMarker: { name: 'classic', size: 8, fill: '#0D496B', stroke: '#0D496B', }, }, }, className: 'x6-edge-animated', }, true ) // 🧠 Inizializza grafo const graph = new Graph({ container: containerRef.current, grid: true, panning: true, mousewheel: true, autoResize: true, connecting: { allowBlank: false, allowLoop: false, snap: true, highlight: true, connector: 'rounded', connectionPoint: 'anchor', createEdge() { return graphRef.current?.createEdge({ shape: 'custom-edge', }) }, validateConnection({ edge, sourceCell, sourceMagnet, targetMagnet, sourcePort, targetPort }) { console.log() // Deve avere una porta di uscita e una di entrata const isSourceOut = sourcePort?.startsWith('out') || sourceCell.ports.items.find((item) => item.id === sourcePort).group === 'out' const isTargetIn = targetPort?.startsWith('in') // console.log(nodesData.find((node) => node.id === edge.getSourceCellId()).next) let next = '' console.log(sourcePort) if (nodesDataRef.current.find((node) => node.id === edge.getSourceCellId()).type === 'logic') { next = nodesDataRef.current .find((node) => node.id === edge.getSourceCellId()) .data.paths.find((path) => path.id === sourcePort)?.next console.log('next logic = ' + next) } else { next = nodesDataRef.current.find((node) => node.id === edge.getSourceCellId())?.next } // Connessione valida solo da out a in return !!(sourceMagnet && targetMagnet && isSourceOut && isTargetIn && !next) }, }, onEdgeLabelRendered: ({ selectors, label }) => { const content = selectors.foContent as HTMLDivElement if (!content) return // estrai il testo in modo tollerante const raw = (label as any)?.attrs const value = raw?.label?.text ?? (typeof raw?.text === 'string' ? raw.text : raw?.text?.text) ?? '' // fallback if (!value) { content.innerHTML = '' return } const div = document.createElement('div') div.innerText = String(value) div.style.padding = '4px 8px' div.style.border = '2px solid #0D496B' div.style.background = '#F0F4F8' div.style.color = '#0D496B' div.style.borderRadius = '6px' div.style.fontWeight = 'bold' div.style.fontSize = '12px' div.style.whiteSpace = 'nowrap' div.style.cursor = 'default' div.style.textAlign = 'center' content.innerHTML = '' content.appendChild(div) }, }) graphRef.current = graph if (props.data && typeof props.data === 'object' && Array.isArray(props.data.nodes)) { const nodeMap = new Map<string, any>() const yStart = 80 const verticalSpacing = 180 // ⬆️ più spazio const horizontalSpacing = 320 let currentY = yStart const xCenter = 400 // 1. Costruzione nodi props.data.nodes.forEach((node: any) => { const { id, name, description = '', type } = node const color = { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', start: '#0D496B', }[type] || '#facc15' let x = xCenter const y = currentY // Se è nodo logico con paths: riserva spazio orizzontale per le uscite if (type === 'logic' && node.data?.paths?.length > 1) { // centriamo il nodo logic al centro delle sue uscite const totalWidth = (node.data.paths.length - 1) * horizontalSpacing x = xCenter + totalWidth / 2 // posiziona più a destra } nodeMap.set(id, { ...node, position: { x, y } }) graph.addNode({ id, shape: id === 'start' ? 'rect' : 'custom-react-node', x, y, width: 300, height: 100, label: name, data: { color, name, description, type, ...(node.data || {}) }, attrs: { label: { text: name }, description: { text: description }, background: { stroke: color }, ...(id === 'start' && { body: { fill: color, stroke: color }, label: { fill: '#fff', fontSize: 18, fontWeight: 800 }, }), }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [ ...(type !== 'start' ? [{ id: 'in1', group: 'in' }] : []), ...(type === 'logic' ? (node.data?.paths || []).map((path: any, i: number) => ({ id: path.id, group: 'out', })) : type !== 'end' ? [{ id: 'out1', group: 'out' }] : []), ], }, tools: id === 'start' || id === 'starter' ? [] : [ { name: 'button-remove', args: { x: '95%', y: 18, offset: { x: -5, y: 0 }, width: 5, height: 5, markup: [ { tagName: 'circle', selector: 'button', attrs: { r: 10, fill: '#fff', stroke: '#fff', strokeWidth: 0, cursor: 'pointer', }, }, { tagName: 'line', selector: 'line1', attrs: { x1: -4, y1: -4, x2: 4, y2: 4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, { tagName: 'line', selector: 'line2', attrs: { x1: -4, y1: 4, x2: 4, y2: -4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, ], }, }, ], }) currentY += verticalSpacing }) // 2. Connessioni props.data.nodes.forEach((node: any) => { const sourceId = node.id if (node.next) { graph.addEdge({ source: { cell: sourceId, port: 'out1' }, target: { cell: node.next, port: 'in1' }, shape: 'custom-edge', }) } if (node.type === 'logic' && node.data?.paths?.length) { const centerX = nodeMap.get(sourceId).position.x const baseY = nodeMap.get(sourceId).position.y + verticalSpacing node.data.paths.forEach((path: any, index: number) => { const targetId = path.next const xOffset = (index - (node.data.paths.length - 1) / 2) * horizontalSpacing // Riposiziona i nodi figli sulla riga successiva, orizzontale const targetNode = graph.getCellById(targetId) if (targetNode && targetNode.isNode()) { targetNode.setPosition(centerX + xOffset, baseY) } graph.addEdge({ source: { cell: sourceId, port: path.id || out{index + 1} }, target: { cell: targetId, port: 'in1' }, shape: 'custom-edge', labels: [ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15, }, text: path.label, }, markup: Markup.getForeignObjectMarkup(), }, ], }) }) } }) } const width = containerRef.current.offsetWidth const starterX = (width - 200) / 2 const starterY = 0 if (!props.data) { graph.addNode({ id: 'starter', shape: 'rect', x: starterX, y: starterY, width: 300, height: 80, label: 'Starter', attrs: { body: { fill: '#0D496B', stroke: '#0D496B', rx: 6, ry: 6 }, label: { fill: '#fff', fontSize: 18, fontWeight: 800 }, }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [{ id: 'in1', group: 'in' }, ...(type !== 'end' ? [{ id: 'out1', group: 'out' }] : [])], }, tools: ['boundary'], movable: false, deletable: false, }) } // ➕ Nodo iniziale graph.on('node:dblclick', ({ node }) => { if (node.shape === 'custom-react-node') { handleNodeDoubleClick(node) } }) graph.on('edge:mouseenter', ({ edge }) => { edge.addTools([{ name: 'button-remove', args: { distance: '50%' } }]) }) graph.on('edge:mouseleave', ({ edge }) => { edge.removeTools() }) graph.on('node:moved', ({ node }) => { const { id } = node const { x, y } = node.getPosition() setNodesData((prev) => prev.map((n) => (n.id === id ? { ...n, position: { x, y } } : n))) }) graph.on('edge:connected', ({ edge, isNew }) => { const graph = graphRef.current if (!graph) return // Assicura forma/stile (anche se createEdge già lo fa) try { edge.setShape('custom-edge') } catch {} edge.setAttrs({ line: { stroke: '#0D496B', strokeWidth: 4, strokeDasharray: 5, targetMarker: { name: 'classic', size: 8, fill: '#0D496B', stroke: '#0D496B' }, }, }) const sourceId = edge.getSourceCellId() const targetId = edge.getTargetCellId() const sourcePortId = edge.getSourcePortId() // es. 'out1' const sourceCell = graph.getCellById(sourceId) const targetCell = graph.getCellById(targetId) if (!sourceCell || !targetCell || !sourceCell.isNode() || !targetCell.isNode()) return const sourceNode = sourceCell as Node const sData = sourceNode.getData() || {} const sType = sData.type // helper: rimuovi altri edge in uscita (per i nodi "normali") const removeOtherOutgoing = () => { const outgoing = graph.getOutgoingEdges(sourceNode) || [] outgoing.forEach((e) => { if (e.id !== edge.id && (e.getSourcePortId() || '').startsWith('out')) { graph.removeEdge(e) } }) } if (sType !== 'logic') { // --- Nodo "normale" --- removeOtherOutgoing() // Aggiorna JSON: source.next = targetId setNodesData((prev) => prev.map((n: any) => (n.id === sourceId ? { ...n, next: targetId ? targetId : sourcePortId } : n)) ) // Niente label sul collegamento "normale" edge.setLabels([]) } else { // --- Nodo "logic" --- // Trova o crea il path associato alla porta (es. out1) const portId = sourcePortId || 'out1' // Aggiorna JSON dei paths let chosenLabel = 'Path' setNodesData((prev) => prev.map((n: any) => { if (n.id !== sourceId) return n const paths: any[] = Array.isArray(n.data?.paths) ? [...n.data.paths] : [] // prova a trovare un path esistente per questa porta const pIndex = paths.findIndex((p: any) => p.portId === portId || p.id === portId) if (pIndex === -1) { // se non c'è, creane uno nuovo minimale const newPath = { id: portId, portId, // importante per riconnettere in futuro label: 'Path', // default next: targetId, } paths.push(newPath) chosenLabel = newPath.label } else { // aggiorna il next del path esistente const updated = { ...paths[pIndex], portId, next: targetId } paths[pIndex] = updated chosenLabel = updated.label || 'Path' } // ritorna nodo logic aggiornato return { ...n, data: { ...(n.data || {}), paths }, } }) ) // Applica label visuale come nei dati iniziali edge.setLabels([ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15 }, text: chosenLabel, }, markup: Markup.getForeignObjectMarkup(), }, ]) } }) graph.on('edge:removed', ({ edge }) => { const sourceId = edge.getSourceCellId() const targetId = edge.getTargetCellId() setNodesData((prev) => prev.map((n: any) => { if (n.id !== sourceId) return n // caso nodo "normale" con next if (n.next === targetId) { const { next, ...rest } = n return rest } // caso nodo "logic" con paths if (n.type === 'logic' && n.data?.paths) { const paths = n.data.paths.map((p: any) => (p.next === targetId ? { ...p, next: undefined } : p)) return { ...n, data: { ...n.data, paths } } } return n }) ) }) // 🔁 Mount dei componenti React graph.on('node:view:mounted', ({ view }) => { const node = view.cell as Node if (node.shape === 'custom-react-node') { const container = view.container.querySelector('[data-selector="foreignObject"]') if (container) { ReactDOM.render(<CustomNodeComponent node={node} onRemove={(id) => removeNodeEverywhere(id)} />, container) } } }) graph.on('node:removed', ({ node }) => { const id = node.id setNodesData((prev) => { const remaining = prev.filter((n: any) => n.id !== id) return remaining.map((n: any) => { if (n.next === id) { const { next, ...rest } = n return rest } if (n.type === 'logic' && n.data?.paths) { const paths = n.data.paths.map((p: any) => (p.next === id ? { ...p, next: undefined } : p)) return { ...n, data: { ...n.data, paths } } } return n }) }) // mantieni pure l’unmount del react component come lo avevi }) // 🔍 Zoom iniziale setTimeout(() => { graph.zoomTo(0.9) }, 300) } requestAnimationFrame(initGraph) }, []) const handleNodeDoubleClick = (node: Node) => { setSelectedNode(node) setFormData(node.getData()) // 👈 Passa direttamente tutto console.log(node.getData()) setModalOpen(true) } const handleModalClose = () => { setModalOpen(false) setSelectedNode(null) setFormData({ name: '', description: '' }) } const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const { name, value } = e.target if (name.includes('.')) { const [parent, child] = name.split('.') setFormData((prev) => ({ ...prev, [parent]: { ...prev[parent], [child]: value, }, })) } else { setFormData((prev) => ({ ...prev, [name]: value, })) } } const removeNodeEverywhere = (id: string) => { const graph = graphRef.current if (!graph) return // 1) aggiorna JSON: elimina il nodo e pulisci i riferimenti a lui setNodesData((prev) => { // elimina il nodo const remaining = prev.filter((n: any) => n.id !== id) // pulisci riferimenti verso il nodo eliminato return remaining.map((n: any) => { const updated: any = { ...n } if (updated.next === id) { // togli il collegamento lineare const { next, ...rest } = updated return rest } if (updated.type === 'logic' && updated.data?.paths) { const cleanedPaths = (updated.data.paths as any[]).map((p) => (p.next === id ? { ...p, next: undefined } : p)) // se vuoi rimuovere proprio il path che puntava al nodo cancellato, usa: // .filter(p => p.next !== undefined) return { ...updated, data: { ...updated.data, paths: cleanedPaths }, } } return updated }) }) // 2) rimuovi dal grafo (X6 toglie anche gli edge collegati) graph.removeNode(id) } const handleModalSubmit = (data: Record<string, any>) => { if (!selectedNode) return const { name, description, type, ...rest } = data const id = selectedNode.id const graph = graphRef.current if (!graph) return // 1. Aggiorna nodo visivamente selectedNode.setLabel(name) selectedNode.setAttrs({ label: { text: name }, description: { text: description }, }) // 2. Aggiorna i dati del nodo X6 selectedNode.updateData(data) // 3. Se è un nodo logic, rigenera le porte e gli edge let cleanedPaths = rest.paths if (type === 'logic' && Array.isArray(rest.paths)) { // Pulisci expressions duplicate cleanedPaths = rest.paths.map((path) => { const cleaned = { ...path } if (cleaned.conditions?.expressions) { delete cleaned.expressions } return cleaned }) // 🔁 Rimuovi solo le porte "out" const currentPorts = selectedNode.getPorts() const outPorts = currentPorts.filter((port) => port.group === 'out') selectedNode.removePorts(outPorts.map((port) => port.id)) // 🆕 Aggiungi nuove porte e assegna portId nei paths const newPorts = cleanedPaths.map((path, index) => { const portId = out{index + 1} path.portId = portId // 🔑 Associazione porta <-> path return { id: portId, group: 'out' } }) selectedNode.addPorts(newPorts) // ⬅️ Forza l'associazione porta -> path PRIMA cleanedPaths.forEach((p, i) => { p.portId = out{i + 1} }) // 🔁 Rimuovi tutti gli edge in uscita dal nodo corrente const outgoingEdges = graph.getOutgoingEdges(selectedNode) || [] outgoingEdges.forEach((edge) => { if (edge.getSourcePortId()?.startsWith('out')) { graph.removeEdge(edge) } }) // ➕ Crea nuovi edge per ogni path con next cleanedPaths.forEach((path) => { if (path.next) { graph.addEdge({ source: { cell: selectedNode.id, port: path.portId, }, target: { cell: path.next, port: 'in1', }, shape: 'custom-edge', labels: [ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15, }, text: path.label || 'Path', }, markup: Markup.getForeignObjectMarkup(), }, ], }) } }) rest.paths = cleanedPaths // aggiorna rest con portId inclusi } // 4. Aggiorna lo stato nodesData setNodesData((prev) => prev.map((n) => n.id === id ? { ...n, name, description, type, data: { ...n.data, ...rest, }, } : n ) ) requestAnimationFrame(() => { resizeNodeFromContent(selectedNode) // se vuoi, riallinea anche la vista // graphRef.current?.centerContent() }) // 5. Chiudi il modal handleModalClose() } const handleDrop = (e: React.DragEvent) => { e.preventDefault() const type = e.dataTransfer.getData('node-type') const graph = graphRef.current if (!graph || !containerRef.current) return const { clientX, clientY } = e const localPoint = graph.clientToLocal({ x: clientX, y: clientY }) const nodeWidth = 300 const nodeHeight = 100 const x = localPoint.x - nodeWidth / 2 const y = localPoint.y - nodeHeight / 2 const nodeInfo = NodeTypes.find((n) => n.type === type) const label = nodeInfo?.label || type const description = nodeInfo?.description || '' const color = { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', }[type] || '#facc15' const id = node-{Date.now()} graph.addNode({ id, shape: 'custom-react-node', x, y, width: nodeWidth, height: nodeHeight, label, data: { color, description, type }, attrs: { label: { text: label }, description: { text: description }, background: { stroke: color }, }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [{ id: 'in1', group: 'in' }, ...(type !== 'end' ? [{ id: 'out1', group: 'out' }] : [])], }, tools: [ { name: 'button-remove', args: { x: '95%', y: 18, offset: { x: -5, y: 0 }, width: 5, height: 5, markup: [ { tagName: 'circle', selector: 'button', attrs: { r: 10, fill: '#fff', stroke: '#fff', strokeWidth: 0, cursor: 'pointer', }, }, { tagName: 'line', selector: 'line1', attrs: { x1: -4, y1: -4, x2: 4, y2: 4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, { tagName: 'line', selector: 'line2', attrs: { x1: -4, y1: 4, x2: 4, y2: -4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, ], }, }, ], }) setNodesData((prev) => [ ...prev, { id, name: label, type, description, position: { x, y }, ...(type === 'logic' ? { data: { paths: [] } } : {}), }, ]) } const handleDragStart = (type: string) => (e: React.DragEvent) => { e.dataTransfer.setData('node-type', type) // Opzionale: rendi invisibile l'elemento "ghost" /* const img = new Image() img.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=' e.dataTransfer.setDragImage(img, 0, 0) */ } const filteredNodes = NodeTypes.filter( (node) => node.label.toLowerCase().includes(filter.toLowerCase()) || node.description.toLowerCase().includes(filter.toLowerCase()) ) // misura il contenuto (label + description) con gli stessi stili del nodo React const resizeNodeFromContent = (node: Node) => { const data = (node.getData?.() as any) || {} const label = node.getLabel?.() as string const description = data?.description ?? '' // --- container "ombra" per misurare --- const ghost = document.createElement('div') ghost.style.position = 'absolute' ghost.style.visibility = 'hidden' ghost.style.pointerEvents = 'none' ghost.style.left = '-10000px' ghost.style.top = '0' // stili identici al tuo CustomNodeComponent ghost.style.background = '#fff' ghost.style.border = 2px solid {data.color || '#facc15'} ghost.style.borderRadius = '12px' ghost.style.padding = '12px' ghost.style.fontFamily = 'Inter, sans-serif' ghost.style.boxSizing = 'border-box' ghost.style.minWidth = '180px' ghost.style.maxWidth = '300px' ghost.style.display = 'flex' ghost.style.flexDirection = 'column' ghost.style.alignItems = 'flex-start' // titolo const title = document.createElement('div') title.style.fontSize = '14px' title.style.fontWeight = '600' title.style.color = '#111827' title.style.marginBottom = '4px' title.style.wordBreak = 'break-word' title.style.whiteSpace = 'normal' title.style.width = '100%' title.textContent = String(label ?? '') // descrizione const desc = document.createElement('p') desc.style.fontSize = '13px' desc.style.color = '#6b7280' desc.style.margin = '6px 0 0' desc.style.lineHeight = '1.4' desc.style.wordBreak = 'break-word' desc.style.whiteSpace = 'normal' desc.style.width = '100%' desc.style.overflowWrap = 'break-word' desc.textContent = String(description ?? '') ghost.appendChild(title) ghost.appendChild(desc) document.body.appendChild(ghost) // misura const width = Math.ceil(ghost.offsetWidth) const height = Math.ceil(ghost.offsetHeight) document.body.removeChild(ghost) // applica al node if (width && height && node) { node.resize(width, height) node.updatePorts() } } insertCss( @keyframes ant-line { to { stroke-dashoffset: -1000; } } .x6-edge-animated path:nth-child(1) { animation: ant-line 30s linear infinite; } .edge-remove-btn { transition: opacity 0.2s ease-in-out; } ) return ( <> <Dialog dismiss={{ outsidePress: false }} open handler={handleOpen} className='w-full h-full !min-w-[100vw] m-0 !min-h-[100vh] overflow-hidden p-4' > <DialogHeader className='flex justify-between items-center'> Workflow Detail <svg onClick={() => handleOpen()} className='cursor-pointer' width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' > <path d='M13.4115 12L18.6952 6.71628C18.7906 6.62419 18.8666 6.51404 18.919 6.39225C18.9713 6.27046 18.9988 6.13947 19 6.00692C19.0011 5.87438 18.9759 5.74293 18.9257 5.62025C18.8755 5.49756 18.8013 5.38611 18.7076 5.29238C18.6139 5.19865 18.5024 5.12453 18.3798 5.07434C18.2571 5.02414 18.1256 4.99889 17.9931 5.00004C17.8605 5.00119 17.7295 5.02873 17.6078 5.08104C17.486 5.13336 17.3758 5.20941 17.2837 5.30475L12 10.5885L6.71628 5.30475C6.528 5.12291 6.27584 5.0223 6.01411 5.02457C5.75237 5.02685 5.502 5.13183 5.31691 5.31691C5.13183 5.502 5.02685 5.75237 5.02457 6.01411C5.0223 6.27584 5.12291 6.528 5.30475 6.71628L10.5885 12L5.30475 17.2837C5.20941 17.3758 5.13336 17.486 5.08104 17.6078C5.02873 17.7295 5.00119 17.8605 5.00004 17.9931C4.99889 18.1256 5.02414 18.2571 5.07434 18.3798C5.12453 18.5024 5.19865 18.6139 5.29238 18.7076C5.38611 18.8013 5.49756 18.8755 5.62025 18.9257C5.74293 18.9759 5.87438 19.0011 6.00692 19C6.13947 18.9988 6.27046 18.9713 6.39225 18.919C6.51404 18.8666 6.62419 18.7906 6.71628 18.6952L12 13.4115L17.2837 18.6952C17.472 18.8771 17.7242 18.9777 17.9859 18.9754C18.2476 18.9732 18.498 18.8682 18.6831 18.6831C18.8682 18.498 18.9732 18.2476 18.9754 17.9859C18.9777 17.7242 18.8771 17.472 18.6952 17.2837L13.4115 12Z' fill='black' ></path> </svg> </DialogHeader> <DialogBody className='h-[calc(100vh-4rem)] p-0'> <div className='flex h-full'> <div className='w-1/5 bg-gray-100 p-4 border-r'> <input placeholder='Search' className='border-b-2 mb-8 border-[#B0C1C9] text-[#3A4A52] w-full px-3 py-2' value={filter} onChange={(e) => setFilter(e.target.value)} /> <div className='space-y-2'> {filteredNodes.map((node) => ( <Card key={node.type} className='cursor-move p-3 rounded-xl border border-gray-300 bg-white shadow' draggable onDragStart={handleDragStart(node.type)} > <div className='flex items-start gap-2'> <div className='w-3 h-3 mt-1 rounded-full flex-shrink-0' style={{ backgroundColor: { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', }[node.type], }} ></div> <div> <div className='font-semibold text-sm text-gray-800'>{node.label}</div> <div className='text-xs text-gray-500'>{node.description}</div> </div> </div> </Card> ))} </div> </div> <div ref={containerRef} className='flex-1 relative h-full bg-gray-100' onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} ></div> </div> </DialogBody> </Dialog> <NodeDetail open={modalOpen} onClose={handleModalClose} onSubmit={handleModalSubmit} formData={formData} onChange={handleInputChange} /> </> ) }
ok! aggiungiamo una “search” nell’header (a sinistra del titolo) che:
name
e description
Qui sotto ti lascio solo gli insert minimi da copiare: 4 step rapidi.
Aggiungi gli import e qualche state/hook:
tsx'use client' import React, { useEffect, useMemo, useRef, useState } from 'react' // 👈 aggiunto useMemo
Sotto agli altri useState
esistenti, aggiungi:
tsx// 🔎 ricerca nodi esistenti nel grafo const [search, setSearch] = useState('') const [showResults, setShowResults] = useState(false)
Crea un helper per centrare/zoommare su un nodo e dargli un piccolo highlight:
tsx// centra + zoom su un nodo esistente (e piccolo highlight) const focusNode = (nodeId: string) => { const graph = graphRef.current if (!graph) return const cell = graph.getCellById(nodeId) if (!cell || !cell.isNode()) return const node = cell as Node const bbox = node.getBBox() const container = graph.container as HTMLDivElement const { width: cw, height: ch } = container.getBoundingClientRect() const padding = 40 // calcola uno zoom "comodo" const zx = cw / (bbox.width + padding) const zy = ch / (bbox.height + padding) const targetZoom = Math.max(0.6, Math.min(1.5, Math.min(zx, zy))) graph.zoomTo(targetZoom, { absolute: true }) graph.centerCell(node) // piccolo highlight temporaneo sul bordo const original = node.getAttrs() node.setAttrs({ body: { ...(original?.body || {}), stroke: '#22c55e', strokeWidth: 3 }, }) setTimeout(() => { node.setAttrs(original || {}) }, 900) }
Crea l’elenco filtrato usando lo stato nodesData
(che già mantieni allineato al grafo):
tsxconst filteredGraphNodes = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return [] // nodesData: [{ id, name, description, ... }] return (nodesData as any[]) .filter(n => (n?.name || '').toLowerCase().includes(q) || (n?.description || '').toLowerCase().includes(q) ) .slice(0, 8) // limita i risultati }, [search, nodesData])
Sostituisci il tuo <DialogHeader ...>
con questa versione che inserisce l’input a sinistra e mantiene titolo + X a destra:
tsx<DialogHeader className='flex justify-between items-center relative'> <div className="flex items-center gap-3 w-1/2"> {/* 🔎 Search nodi del grafo */} <div className="relative w-full"> <input value={search} onChange={(e) => { setSearch(e.target.value); setShowResults(true) }} onFocus={() => setShowResults(true)} placeholder="Cerca nodo per nome o descrizione…" className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0D496B]" /> {/* Risultati */} {showResults && search && ( <div className="absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg" onMouseDown={(e) => e.preventDefault()} // evita blur su click > {filteredGraphNodes.length === 0 && ( <div className="px-3 py-2 text-sm text-gray-500">Nessun risultato</div> )} {filteredGraphNodes.map((n: any) => ( <button key={n.id} className="flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-gray-50" onClick={() => { focusNode(n.id) setShowResults(false) }} > <span className="mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full" style={{ backgroundColor: { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', start: '#0D496B' }[n.type] || '#facc15' }} /> <span className="flex min-w-0 flex-col"> <span className="truncate text-sm font-medium text-gray-800">{n.name || n.id}</span> {n.description && ( <span className="line-clamp-2 text-xs text-gray-500">{n.description}</span> )} </span> </button> ))} </div> )} </div> </div> {/* Titolo + Close */} <div className="flex items-center gap-3"> <span>Workflow Detail</span> <svg onClick={() => handleOpen()} className='cursor-pointer' width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'> <path d='M13.4115 12L18.6952 6.71628C18.7906 6.62419 18.8666 6.51404 18.919 6.39225C18.9713 6.27046 18.9988 6.13947 19 6.00692C19.0011 5.87438 18.9759 5.74293 18.9257 5.62025C18.8755 5.49756 18.8013 5.38611 18.7076 5.29238C18.6139 5.19865 18.5024 5.12453 18.3798 5.07434C18.2571 5.02414 18.1256 4.99889 17.9931 5.00004C17.8605 5.00119 17.7295 5.02873 17.6078 5.08104C17.486 5.13336 17.3758 5.20941 17.2837 5.30475L12 10.5885L6.71628 5.30475C6.528 5.12291 6.27584 5.0223 6.01411 5.02457C5.75237 5.02685 5.502 5.13183 5.31691 5.31691C5.13183 5.502 5.02685 5.75237 5.02457 6.01411C5.0223 6.27584 5.12291 6.528 5.30475 6.71628L10.5885 12L5.30475 17.2837C5.20941 17.3758 5.13336 17.486 5.08104 17.6078C5.02873 17.7295 5.00119 17.8605 5.00004 17.9931C4.99889 18.1256 5.02414 18.2571 5.07434 18.3798C5.12453 18.5024 5.19865 18.6139 5.29238 18.7076C5.38611 18.8013 5.49756 18.8755 5.62025 18.9257C5.74293 18.9759 5.87438 19.0011 6.00692 19C6.13947 18.9988 6.27046 18.9713 6.39225 18.919C6.62419 18.8666 6.6952 18.6952 12 13.4115 17.2837 18.6952Z' fill='black'></path> </svg> </div> </DialogHeader>
Nota: ho messo l’input a sinistra, con dropdown risultati. Chiudi la lista cliccando un risultato o cliccando fuori (puoi anche aggiungere un
onBlur
se vuoi nasconderla quando l’input perde il focus).
Aggiungi un piccolo handler globale (opzionale):
tsxuseEffect(() => { const onDocClick = (e: MouseEvent) => { // se il target non sta nell’header search, chiudi const el = (e.target as HTMLElement) if (!el.closest('.header-search')) setShowResults(false) } document.addEventListener('click', onDocClick) return () => document.removeEventListener('click', onDocClick) }, [])
E nel markup dell’input avvolgi il container con className="header-search"
:
tsx<div className="relative w-full header-search"> {/* ...input + lista... */} </div>
Se vuoi, puoi permettere “Enter” per il primo risultato:
tsxconst firstHit = filteredGraphNodes[0] const onKeyDownSearch = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter' && firstHit) { focusNode(firstHit.id) setShowResults(false) } }
E sull’<input />
aggiungi onKeyDown={onKeyDownSearch}
.
nodesData
come fonte verità (già sincronizzata con X6 quando muovi/editi).focusNode
calcola uno zoom naturale per il nodo e lo centra, senza plugin extra.Se vuoi invece un comportamento “fit to bounds” di più nodi/edge, si può estendere focusNode
usando il BBox
del nodo + padding custom. Vuoi che la ricerca resti anche nel pannello laterale e non nell’header? Dimmi come preferisci e te la adatto in un attimo.
ok perfetto, ora riesci a creare un bottone che se schiacciato riordina i nodi e gli edge creando un diagramma a scala come quando carica il workflow?
'use client'
import { useTranslation } from '@/app/i18n/client'
import { Graph, Markup, Node } from '@antv/x6'
import { Card, Dialog, DialogBody, DialogHeader, Input } from '@material-tailwind/react'
import insertCss from 'insert-css'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { NodeDetail } from './NodeDetail'
const CustomNodeComponent = ({ node, onRemove }: { node: Node; onRemove: (id: string) => void }) => {
const data = node.getData()
const { description, color } = data || {}
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
if (offsetWidth && offsetHeight) {
node.resize(offsetWidth, offsetHeight)
node.updatePorts()
}
}
}
const observer = new ResizeObserver(updateSize)
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => {
observer.disconnect()
}
}, [node])
return (
<div
ref={containerRef}
style={{
backgroundColor: '#fff',
border: 2px solid ${color}
,
borderRadius: '12px',
padding: '12px',
fontFamily: 'Inter, sans-serif',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
boxSizing: 'border-box',
minWidth: '180px',
maxWidth: '300px',
position: 'relative',
cursor: 'move',
}}
>
<button
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'transparent',
border: 'none',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
color: 'red',
padding: '0',
lineHeight: '1',
zIndex: '10',
}}
onClick={(e) => {
e.stopPropagation()
onRemove(node.id)
}}
>
×
</button>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#111827',
marginBottom: '4px',
wordBreak: 'break-word',
whiteSpace: 'normal',
width: '100%',
}}
>
{node.getLabel()}
</div>
<p
style={{
fontSize: '13px',
color: '#6b7280',
margin: '6px 0 0',
lineHeight: '1.4',
wordBreak: 'break-word',
whiteSpace: 'normal',
width: '100%',
overflowWrap: 'break-word',
}}
>
{description}
</p>
</div>
)
}
const NodeTypes = [
{ label: 'Email', type: 'email', description: 'Enviar email al lead (basado en template + datos lead)' },
{ label: 'WhatsApp', type: 'whatsapp', description: 'Enviar mensaje WA (template, número, texto dinámico)' },
{ label: 'Llamada', type: 'call', description: 'Encolar llamada (via API o Twilio Studio)' },
{ label: 'Espera', type: 'delay', description: 'Espera X horas antes del siguiente bloque' },
{
label: 'Lógica',
type: 'logic',
description: 'Evaluar condición y redirigir a uno u más paths posibles (como Zapier "Paths")',
},
{ label: 'Fin', type: 'end', description: 'Fin del workflow' },
]
Graph.registerNode(
'custom-react-node',
{
inherit: 'rect',
width: 300,
height: 100,
markup: [
{ tagName: 'rect', selector: 'background' },
{ tagName: 'rect', selector: 'body' },
{ tagName: 'text', selector: 'label' },
{ tagName: 'text', selector: 'description' },
],
attrs: {
background: {
refWidth: '90%',
refHeight: '60%',
refX: '5%',
refY: '20%',
fill: '#f3f4f6',
stroke: '#000000',
strokeWidth: 2,
rx: 8,
ry: 8,
},
body: {
refWidth: '100%',
refHeight: '100%',
stroke: '#facc15',
strokeWidth: 2,
fill: 'white',
rx: 12,
ry: 12,
},
label: {
refX: '5%',
refY: '30%',
textAnchor: 'start',
textVerticalAnchor: 'middle',
fontSize: 14,
fill: '#111827',
fontWeight: 'bold',
},
description: {
refX: '5%',
refY: '45%',
textAnchor: 'start',
textVerticalAnchor: 'top',
fontSize: 12,
fill: '#59626fdb',
textWrap: {
width: 260,
lineHeight: 2,
breakWord: true,
},
lineHeight: '15px',
},
},
},
true
)
export function WorkflowDetail(props: { handleOpen: () => void; data: unknown }) {
const { handleOpen } = props
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const graphRef = useRef<Graph | null>(null)
const [filter, setFilter] = useState('')
const [nodesData, setNodesData] = useState<[]>(props.data.nodes)
const [modalOpen, setModalOpen] = useState(false)
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
const [formData, setFormData] = useState<Record<string, any>>({})
const [search, setSearch] = useState('')
const [showResults, setShowResults] = useState(false)
const filteredGraphNodes = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return []
// nodesData: [{ id, name, description, ... }]
return (nodesData as any[])
.filter((n) => (n?.name || '').toLowerCase().includes(q) || (n?.description || '').toLowerCase().includes(q))
.slice(0, 8) // limita i risultati
}, [search, nodesData])
useEffect(() => {
console.log('JSON aggiornato:', nodesData)
if (graphRef && graphRef.current) console.log(graphRef.current.getNodes())
}, [nodesData])
useEffect(() => {
if (graphRef && graphRef.current) console.log(graphRef.current.getNodes())
}, [graphRef])
const nodesDataRef = useRef(nodesData)
useEffect(() => {
nodesDataRef.current = nodesData
}, [nodesData])
useEffect(() => {
const initGraph = () => {
if (!containerRef.current || graphRef.current) return
text// 🔷 Registra lo stile della connessione Graph.registerEdge( 'custom-edge', { inherit: 'edge', connector: { name: 'rounded' }, attrs: { line: { style: { animation: 'ant-line 30s infinite linear', }, stroke: '#0D496B', strokeWidth: 4, strokeDasharray: 5, strokeDashoffset: 0, targetMarker: { name: 'classic', size: 8, fill: '#0D496B', stroke: '#0D496B', }, }, }, className: 'x6-edge-animated', }, true ) // 🧠 Inizializza grafo const graph = new Graph({ container: containerRef.current, grid: true, panning: true, mousewheel: true, autoResize: true, connecting: { allowBlank: false, allowLoop: false, snap: true, highlight: true, connector: 'rounded', connectionPoint: 'anchor', createEdge() { return graphRef.current?.createEdge({ shape: 'custom-edge', }) }, validateConnection({ edge, sourceCell, sourceMagnet, targetMagnet, sourcePort, targetPort }) { console.log() // Deve avere una porta di uscita e una di entrata const isSourceOut = sourcePort?.startsWith('out') || sourceCell.ports.items.find((item) => item.id === sourcePort).group === 'out' const isTargetIn = targetPort?.startsWith('in') // console.log(nodesData.find((node) => node.id === edge.getSourceCellId()).next) let next = '' console.log(sourcePort) if (nodesDataRef.current.find((node) => node.id === edge.getSourceCellId()).type === 'logic') { next = nodesDataRef.current .find((node) => node.id === edge.getSourceCellId()) .data.paths.find((path) => path.id === sourcePort)?.next console.log('next logic = ' + next) } else { next = nodesDataRef.current.find((node) => node.id === edge.getSourceCellId())?.next } // Connessione valida solo da out a in return !!(sourceMagnet && targetMagnet && isSourceOut && isTargetIn && !next) }, }, onEdgeLabelRendered: ({ selectors, label }) => { const content = selectors.foContent as HTMLDivElement if (!content) return // estrai il testo in modo tollerante const raw = (label as any)?.attrs const value = raw?.label?.text ?? (typeof raw?.text === 'string' ? raw.text : raw?.text?.text) ?? '' // fallback if (!value) { content.innerHTML = '' return } const div = document.createElement('div') div.innerText = String(value) div.style.padding = '4px 8px' div.style.border = '2px solid #0D496B' div.style.background = '#F0F4F8' div.style.color = '#0D496B' div.style.borderRadius = '6px' div.style.fontWeight = 'bold' div.style.fontSize = '12px' div.style.whiteSpace = 'nowrap' div.style.cursor = 'default' div.style.textAlign = 'center' content.innerHTML = '' content.appendChild(div) }, }) graphRef.current = graph if (props.data && typeof props.data === 'object' && Array.isArray(props.data.nodes)) { const nodeMap = new Map<string, any>() const yStart = 80 const verticalSpacing = 180 // ⬆️ più spazio const horizontalSpacing = 320 let currentY = yStart const xCenter = 400 // 1. Costruzione nodi props.data.nodes.forEach((node: any) => { const { id, name, description = '', type } = node const color = { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', start: '#0D496B', }[type] || '#facc15' let x = xCenter const y = currentY // Se è nodo logico con paths: riserva spazio orizzontale per le uscite if (type === 'logic' && node.data?.paths?.length > 1) { // centriamo il nodo logic al centro delle sue uscite const totalWidth = (node.data.paths.length - 1) * horizontalSpacing x = xCenter + totalWidth / 2 // posiziona più a destra } nodeMap.set(id, { ...node, position: { x, y } }) graph.addNode({ id, shape: id === 'start' ? 'rect' : 'custom-react-node', x, y, width: 300, height: 100, label: name, data: { color, name, description, type, ...(node.data || {}) }, attrs: { label: { text: name }, description: { text: description }, background: { stroke: color }, ...(id === 'start' && { body: { fill: color, stroke: color }, label: { fill: '#fff', fontSize: 18, fontWeight: 800 }, }), }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [ ...(type !== 'start' ? [{ id: 'in1', group: 'in' }] : []), ...(type === 'logic' ? (node.data?.paths || []).map((path: any, i: number) => ({ id: path.id, group: 'out', })) : type !== 'end' ? [{ id: 'out1', group: 'out' }] : []), ], }, tools: id === 'start' || id === 'starter' ? [] : [ { name: 'button-remove', args: { x: '95%', y: 18, offset: { x: -5, y: 0 }, width: 5, height: 5, markup: [ { tagName: 'circle', selector: 'button', attrs: { r: 10, fill: '#fff', stroke: '#fff', strokeWidth: 0, cursor: 'pointer', }, }, { tagName: 'line', selector: 'line1', attrs: { x1: -4, y1: -4, x2: 4, y2: 4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, { tagName: 'line', selector: 'line2', attrs: { x1: -4, y1: 4, x2: 4, y2: -4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, ], }, }, ], }) currentY += verticalSpacing }) // 2. Connessioni props.data.nodes.forEach((node: any) => { const sourceId = node.id if (node.next) { graph.addEdge({ source: { cell: sourceId, port: 'out1' }, target: { cell: node.next, port: 'in1' }, shape: 'custom-edge', }) } if (node.type === 'logic' && node.data?.paths?.length) { const centerX = nodeMap.get(sourceId).position.x const baseY = nodeMap.get(sourceId).position.y + verticalSpacing node.data.paths.forEach((path: any, index: number) => { const targetId = path.next const xOffset = (index - (node.data.paths.length - 1) / 2) * horizontalSpacing // Riposiziona i nodi figli sulla riga successiva, orizzontale const targetNode = graph.getCellById(targetId) if (targetNode && targetNode.isNode()) { targetNode.setPosition(centerX + xOffset, baseY) } graph.addEdge({ source: { cell: sourceId, port: path.id || `out${index + 1}` }, target: { cell: targetId, port: 'in1' }, shape: 'custom-edge', labels: [ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15, }, text: path.label, }, markup: Markup.getForeignObjectMarkup(), }, ], }) }) } }) } const width = containerRef.current.offsetWidth const starterX = (width - 200) / 2 const starterY = 0 if (!props.data) { graph.addNode({ id: 'starter', shape: 'rect', x: starterX, y: starterY, width: 300, height: 80, label: 'Starter', attrs: { body: { fill: '#0D496B', stroke: '#0D496B', rx: 6, ry: 6 }, label: { fill: '#fff', fontSize: 18, fontWeight: 800 }, }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [{ id: 'in1', group: 'in' }, ...(type !== 'end' ? [{ id: 'out1', group: 'out' }] : [])], }, tools: ['boundary'], movable: false, deletable: false, }) } // ➕ Nodo iniziale graph.on('node:dblclick', ({ node }) => { if (node.shape === 'custom-react-node') { handleNodeDoubleClick(node) } }) graph.on('edge:mouseenter', ({ edge }) => { edge.addTools([{ name: 'button-remove', args: { distance: '50%' } }]) }) graph.on('edge:mouseleave', ({ edge }) => { edge.removeTools() }) graph.on('node:moved', ({ node }) => { const { id } = node const { x, y } = node.getPosition() setNodesData((prev) => prev.map((n) => (n.id === id ? { ...n, position: { x, y } } : n))) }) graph.on('edge:connected', ({ edge, isNew }) => { const graph = graphRef.current if (!graph) return // Assicura forma/stile (anche se createEdge già lo fa) try { edge.setShape('custom-edge') } catch {} edge.setAttrs({ line: { stroke: '#0D496B', strokeWidth: 4, strokeDasharray: 5, targetMarker: { name: 'classic', size: 8, fill: '#0D496B', stroke: '#0D496B' }, }, }) const sourceId = edge.getSourceCellId() const targetId = edge.getTargetCellId() const sourcePortId = edge.getSourcePortId() // es. 'out1' const sourceCell = graph.getCellById(sourceId) const targetCell = graph.getCellById(targetId) if (!sourceCell || !targetCell || !sourceCell.isNode() || !targetCell.isNode()) return const sourceNode = sourceCell as Node const sData = sourceNode.getData() || {} const sType = sData.type // helper: rimuovi altri edge in uscita (per i nodi "normali") const removeOtherOutgoing = () => { const outgoing = graph.getOutgoingEdges(sourceNode) || [] outgoing.forEach((e) => { if (e.id !== edge.id && (e.getSourcePortId() || '').startsWith('out')) { graph.removeEdge(e) } }) } if (sType !== 'logic') { // --- Nodo "normale" --- removeOtherOutgoing() // Aggiorna JSON: source.next = targetId setNodesData((prev) => prev.map((n: any) => (n.id === sourceId ? { ...n, next: targetId ? targetId : sourcePortId } : n)) ) // Niente label sul collegamento "normale" edge.setLabels([]) } else { // --- Nodo "logic" --- // Trova o crea il path associato alla porta (es. out1) const portId = sourcePortId || 'out1' // Aggiorna JSON dei paths let chosenLabel = 'Path' setNodesData((prev) => prev.map((n: any) => { if (n.id !== sourceId) return n const paths: any[] = Array.isArray(n.data?.paths) ? [...n.data.paths] : [] // prova a trovare un path esistente per questa porta const pIndex = paths.findIndex((p: any) => p.portId === portId || p.id === portId) if (pIndex === -1) { // se non c'è, creane uno nuovo minimale const newPath = { id: portId, portId, // importante per riconnettere in futuro label: 'Path', // default next: targetId, } paths.push(newPath) chosenLabel = newPath.label } else { // aggiorna il next del path esistente const updated = { ...paths[pIndex], portId, next: targetId } paths[pIndex] = updated chosenLabel = updated.label || 'Path' } // ritorna nodo logic aggiornato return { ...n, data: { ...(n.data || {}), paths }, } }) ) // Applica label visuale come nei dati iniziali edge.setLabels([ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15 }, text: chosenLabel, }, markup: Markup.getForeignObjectMarkup(), }, ]) } }) graph.on('edge:removed', ({ edge }) => { const sourceId = edge.getSourceCellId() const targetId = edge.getTargetCellId() setNodesData((prev) => prev.map((n: any) => { if (n.id !== sourceId) return n // caso nodo "normale" con next if (n.next === targetId) { const { next, ...rest } = n return rest } // caso nodo "logic" con paths if (n.type === 'logic' && n.data?.paths) { const paths = n.data.paths.map((p: any) => (p.next === targetId ? { ...p, next: undefined } : p)) return { ...n, data: { ...n.data, paths } } } return n }) ) }) // 🔁 Mount dei componenti React graph.on('node:view:mounted', ({ view }) => { const node = view.cell as Node if (node.shape === 'custom-react-node') { const container = view.container.querySelector('[data-selector="foreignObject"]') if (container) { ReactDOM.render(<CustomNodeComponent node={node} onRemove={(id) => removeNodeEverywhere(id)} />, container) } } }) graph.on('node:removed', ({ node }) => { const id = node.id setNodesData((prev) => { const remaining = prev.filter((n: any) => n.id !== id) return remaining.map((n: any) => { if (n.next === id) { const { next, ...rest } = n return rest } if (n.type === 'logic' && n.data?.paths) { const paths = n.data.paths.map((p: any) => (p.next === id ? { ...p, next: undefined } : p)) return { ...n, data: { ...n.data, paths } } } return n }) }) // mantieni pure l’unmount del react component come lo avevi }) // 🔍 Zoom iniziale setTimeout(() => { graph.zoomTo(0.9) }, 300) } requestAnimationFrame(initGraph)
}, [])
const focusNode = (nodeId: string) => {
const graph = graphRef.current
if (!graph) return
const cell = graph.getCellById(nodeId)
if (!cell || !cell.isNode()) return
textconst node = cell as Node const bbox = node.getBBox() const container = graph.container as HTMLDivElement const { width: cw, height: ch } = container.getBoundingClientRect() const padding = 40 // calcola uno zoom "comodo" const zx = cw / (bbox.width + padding) const zy = ch / (bbox.height + padding) const targetZoom = Math.max(0.6, Math.min(1.5, Math.min(zx, zy))) graph.zoomTo(targetZoom, { absolute: true }) graph.centerCell(node) // piccolo highlight temporaneo sul bordo const original = node.getAttrs() node.setAttrs({ body: { ...(original?.body || {}), stroke: '#22c55e', strokeWidth: 3 }, }) setTimeout(() => { node.setAttrs(original || {}) }, 900)
}
const handleNodeDoubleClick = (node: Node) => {
setSelectedNode(node)
setFormData(node.getData()) // 👈 Passa direttamente tutto
console.log(node.getData())
setModalOpen(true)
}
const handleModalClose = () => {
setModalOpen(false)
setSelectedNode(null)
setFormData({ name: '', description: '' })
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
textif (name.includes('.')) { const [parent, child] = name.split('.') setFormData((prev) => ({ ...prev, [parent]: { ...prev[parent], [child]: value, }, })) } else { setFormData((prev) => ({ ...prev, [name]: value, })) }
}
const removeNodeEverywhere = (id: string) => {
const graph = graphRef.current
if (!graph) return
text// 1) aggiorna JSON: elimina il nodo e pulisci i riferimenti a lui setNodesData((prev) => { // elimina il nodo const remaining = prev.filter((n: any) => n.id !== id) // pulisci riferimenti verso il nodo eliminato return remaining.map((n: any) => { const updated: any = { ...n } if (updated.next === id) { // togli il collegamento lineare const { next, ...rest } = updated return rest } if (updated.type === 'logic' && updated.data?.paths) { const cleanedPaths = (updated.data.paths as any[]).map((p) => (p.next === id ? { ...p, next: undefined } : p)) // se vuoi rimuovere proprio il path che puntava al nodo cancellato, usa: // .filter(p => p.next !== undefined) return { ...updated, data: { ...updated.data, paths: cleanedPaths }, } } return updated }) }) // 2) rimuovi dal grafo (X6 toglie anche gli edge collegati) graph.removeNode(id)
}
const handleModalSubmit = (data: Record<string, any>) => {
if (!selectedNode) return
textconst { name, description, type, ...rest } = data const id = selectedNode.id const graph = graphRef.current if (!graph) return // 1. Aggiorna nodo visivamente selectedNode.setLabel(name) selectedNode.setAttrs({ label: { text: name }, description: { text: description }, }) // 2. Aggiorna i dati del nodo X6 selectedNode.updateData(data) // 3. Se è un nodo logic, rigenera le porte e gli edge let cleanedPaths = rest.paths if (type === 'logic' && Array.isArray(rest.paths)) { // Pulisci expressions duplicate cleanedPaths = rest.paths.map((path) => { const cleaned = { ...path } if (cleaned.conditions?.expressions) { delete cleaned.expressions } return cleaned }) // 🔁 Rimuovi solo le porte "out" const currentPorts = selectedNode.getPorts() const outPorts = currentPorts.filter((port) => port.group === 'out') selectedNode.removePorts(outPorts.map((port) => port.id)) // 🆕 Aggiungi nuove porte e assegna portId nei paths const newPorts = cleanedPaths.map((path, index) => { const portId = `out${index + 1}` path.portId = portId // 🔑 Associazione porta <-> path return { id: portId, group: 'out' } }) selectedNode.addPorts(newPorts) // ⬅️ Forza l'associazione porta -> path PRIMA cleanedPaths.forEach((p, i) => { p.portId = `out${i + 1}` }) // 🔁 Rimuovi tutti gli edge in uscita dal nodo corrente const outgoingEdges = graph.getOutgoingEdges(selectedNode) || [] outgoingEdges.forEach((edge) => { if (edge.getSourcePortId()?.startsWith('out')) { graph.removeEdge(edge) } }) // ➕ Crea nuovi edge per ogni path con `next` cleanedPaths.forEach((path) => { if (path.next) { graph.addEdge({ source: { cell: selectedNode.id, port: path.portId, }, target: { cell: path.next, port: 'in1', }, shape: 'custom-edge', labels: [ { position: 0.5, attrs: { fo: { width: 100, height: 30, x: -50, y: -15, }, text: path.label || 'Path', }, markup: Markup.getForeignObjectMarkup(), }, ], }) } }) rest.paths = cleanedPaths // aggiorna rest con portId inclusi } // 4. Aggiorna lo stato nodesData setNodesData((prev) => prev.map((n) => n.id === id ? { ...n, name, description, type, data: { ...n.data, ...rest, }, } : n ) ) requestAnimationFrame(() => { resizeNodeFromContent(selectedNode) // se vuoi, riallinea anche la vista // graphRef.current?.centerContent() }) // 5. Chiudi il modal handleModalClose()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
textconst type = e.dataTransfer.getData('node-type') const graph = graphRef.current if (!graph || !containerRef.current) return const { clientX, clientY } = e const localPoint = graph.clientToLocal({ x: clientX, y: clientY }) const nodeWidth = 300 const nodeHeight = 100 const x = localPoint.x - nodeWidth / 2 const y = localPoint.y - nodeHeight / 2 const nodeInfo = NodeTypes.find((n) => n.type === type) const label = nodeInfo?.label || type const description = nodeInfo?.description || '' const color = { email: '#2563eb', whatsapp: '#22c55e', call: '#7c3aed', delay: '#f97316', logic: '#10b981', end: '#ef4444', }[type] || '#facc15' const id = `node-${Date.now()}` graph.addNode({ id, shape: 'custom-react-node', x, y, width: nodeWidth, height: nodeHeight, label, data: { color, description, type }, attrs: { label: { text: label }, description: { text: description }, background: { stroke: color }, }, ports: { groups: { in: { position: 'top', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, out: { position: 'bottom', attrs: { circle: { r: 6, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff', }, }, }, }, items: [{ id: 'in1', group: 'in' }, ...(type !== 'end' ? [{ id: 'out1', group: 'out' }] : [])], }, tools: [ { name: 'button-remove', args: { x: '95%', y: 18, offset: { x: -5, y: 0 }, width: 5, height: 5, markup: [ { tagName: 'circle', selector: 'button', attrs: { r: 10, fill: '#fff', stroke: '#fff', strokeWidth: 0, cursor: 'pointer', }, }, { tagName: 'line', selector: 'line1', attrs: { x1: -4, y1: -4, x2: 4, y2: 4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, { tagName: 'line', selector: 'line2', attrs: { x1: -4, y1: 4, x2: 4, y2: -4, stroke: '#ef4444', strokeWidth: 1.2, strokeLinecap: 'round', }, }, ], }, }, ], }) setNodesData((prev) => [ ...prev, { id, name: label, type, description, position: { x, y }, ...(type === 'logic' ? { data: { paths: [] } } : {}), }, ])
}
const handleDragStart = (type: string) => (e: React.DragEvent) => {
e.dataTransfer.setData('node-type', type)
text// Opzionale: rendi invisibile l'elemento "ghost" /* const img = new Image() img.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=' e.dataTransfer.setDragImage(img, 0, 0) */
}
const filteredNodes = NodeTypes.filter(
(node) =>
node.label.toLowerCase().includes(filter.toLowerCase()) ||
node.description.toLowerCase().includes(filter.toLowerCase())
)
// misura il contenuto (label + description) con gli stessi stili del nodo React
const resizeNodeFromContent = (node: Node) => {
const data = (node.getData?.() as any) || {}
const label = node.getLabel?.() as string
const description = data?.description ?? ''
text// --- container "ombra" per misurare --- const ghost = document.createElement('div') ghost.style.position = 'absolute' ghost.style.visibility = 'hidden' ghost.style.pointerEvents = 'none' ghost.style.left = '-10000px' ghost.style.top = '0' // stili identici al tuo CustomNodeComponent ghost.style.background = '#fff' ghost.style.border = `2px solid ${data.color || '#facc15'}` ghost.style.borderRadius = '12px' ghost.style.padding = '12px' ghost.style.fontFamily = 'Inter, sans-serif' ghost.style.boxSizing = 'border-box' ghost.style.minWidth = '180px' ghost.style.maxWidth = '300px' ghost.style.display = 'flex' ghost.style.flexDirection = 'column' ghost.style.alignItems = 'flex-start' // titolo const title = document.createElement('div') title.style.fontSize = '14px' title.style.fontWeight = '600' title.style.color = '#111827' title.style.marginBottom = '4px' title.style.wordBreak = 'break-word' title.style.whiteSpace = 'normal' title.style.width = '100%' title.textContent = String(label ?? '') // descrizione const desc = document.createElement('p') desc.style.fontSize = '13px' desc.style.color = '#6b7280' desc.style.margin = '6px 0 0' desc.style.lineHeight = '1.4' desc.style.wordBreak = 'break-word' desc.style.whiteSpace = 'normal' desc.style.width = '100%' desc.style.overflowWrap = 'break-word' desc.textContent = String(description ?? '') ghost.appendChild(title) ghost.appendChild(desc) document.body.appendChild(ghost) // misura const width = Math.ceil(ghost.offsetWidth) const height = Math.ceil(ghost.offsetHeight) document.body.removeChild(ghost) // applica al node if (width && height && node) { node.resize(width, height) node.updatePorts() }
}
insertCss(`
@keyframes ant-line {
to {
stroke-dashoffset: -1000;
}
}
.x6-edge-animated path:nth-child(1) {
animation: ant-line 30s linear infinite;
}
.edge-remove-btn {
transition: opacity 0.2s ease-in-out;
}
`)
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
// se il target non sta nell’header search, chiudi
const el = e.target as HTMLElement
if (!el.closest('.header-search')) setShowResults(false)
}
document.addEventListener('click', onDocClick)
return () => document.removeEventListener('click', onDocClick)
}, [])
return (
<>
<Dialog
dismiss={{ outsidePress: false }}
open
handler={handleOpen}
className='w-full h-full !min-w-[100vw] m-0 !min-h-[100vh] overflow-hidden p-4'
>
<DialogHeader className='flex justify-between items-center'>
Workflow Detail
<div>
<div className='relative w-full header-search flex gap-4'>
<div className='relative w-full'>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setShowResults(true)
}}
variant='standard'
label={'Search node'}
onFocus={() => setShowResults(true)}
className='border-b-2 mb-8 border-[#B0C1C9] text-[#3A4A52] w-full px-3 py-2'
/>
{/* Risultati */}
{showResults && search && (
<div
className='absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg'
onMouseDown={(e) => e.preventDefault()} // evita blur su click
>
{filteredGraphNodes.length === 0 && (
<div className='px-3 py-2 text-sm text-gray-500'>Nessun risultato</div>
)}
{filteredGraphNodes.map((n: any) => (
<button
key={n.id}
className='flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-gray-50'
onClick={() => {
focusNode(n.id)
setShowResults(false)
}}
>
<span
className='mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full'
style={{
backgroundColor:
{
email: '#2563eb',
whatsapp: '#22c55e',
call: '#7c3aed',
delay: '#f97316',
logic: '#10b981',
end: '#ef4444',
start: '#0D496B',
}[n.type] || '#facc15',
}}
/>
<span className='flex min-w-0 flex-col'>
<span className='truncate text-sm font-medium text-gray-800'>{n.name || n.id}</span>
{n.description && <span className='line-clamp-2 text-xs text-gray-500'>{n.description}</span>}
</span>
</button>
))}
</div>
)}
</div>
<svg
onClick={() => handleOpen()}
className='cursor-pointer'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M13.4115 12L18.6952 6.71628C18.7906 6.62419 18.8666 6.51404 18.919 6.39225C18.9713 6.27046 18.9988 6.13947 19 6.00692C19.0011 5.87438 18.9759 5.74293 18.9257 5.62025C18.8755 5.49756 18.8013 5.38611 18.7076 5.29238C18.6139 5.19865 18.5024 5.12453 18.3798 5.07434C18.2571 5.02414 18.1256 4.99889 17.9931 5.00004C17.8605 5.00119 17.7295 5.02873 17.6078 5.08104C17.486 5.13336 17.3758 5.20941 17.2837 5.30475L12 10.5885L6.71628 5.30475C6.528 5.12291 6.27584 5.0223 6.01411 5.02457C5.75237 5.02685 5.502 5.13183 5.31691 5.31691C5.13183 5.502 5.02685 5.75237 5.02457 6.01411C5.0223 6.27584 5.12291 6.528 5.30475 6.71628L10.5885 12L5.30475 17.2837C5.20941 17.3758 5.13336 17.486 5.08104 17.6078C5.02873 17.7295 5.00119 17.8605 5.00004 17.9931C4.99889 18.1256 5.02414 18.2571 5.07434 18.3798C5.12453 18.5024 5.19865 18.6139 5.29238 18.7076C5.38611 18.8013 5.49756 18.8755 5.62025 18.9257C5.74293 18.9759 5.87438 19.0011 6.00692 19C6.13947 18.9988 6.27046 18.9713 6.39225 18.919C6.51404 18.8666 6.62419 18.7906 6.71628 18.6952L12 13.4115L17.2837 18.6952C17.472 18.8771 17.7242 18.9777 17.9859 18.9754C18.2476 18.9732 18.498 18.8682 18.6831 18.6831C18.8682 18.498 18.9732 18.2476 18.9754 17.9859C18.9777 17.7242 18.8771 17.472 18.6952 17.2837L13.4115 12Z'
fill='black'
></path>
</svg>
</div>
</div>
</DialogHeader>
<DialogBody className='h-[calc(100vh-4rem)] p-0'>
<div className='flex h-full'>
<div className='w-1/5 bg-gray-100 p-4 border-r'>
<input
placeholder='Search'
className='border-b-2 mb-8 border-[#B0C1C9] text-[#3A4A52] w-full px-3 py-2'
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className='space-y-2'>
{filteredNodes.map((node) => (
<Card
key={node.type}
className='cursor-move p-3 rounded-xl border border-gray-300 bg-white shadow'
draggable
onDragStart={handleDragStart(node.type)}
>
<div className='flex items-start gap-2'>
<div
className='w-3 h-3 mt-1 rounded-full flex-shrink-0'
style={{
backgroundColor: {
email: '#2563eb',
whatsapp: '#22c55e',
call: '#7c3aed',
delay: '#f97316',
logic: '#10b981',
end: '#ef4444',
}[node.type],
}}
></div>
<div>
<div className='font-semibold text-sm text-gray-800'>{node.label}</div>
<div className='text-xs text-gray-500'>{node.description}</div>
</div>
</div>
</Card>
))}
</div>
</div>
text<div ref={containerRef} className='flex-1 relative h-full bg-gray-100' onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} ></div> </div> </DialogBody> </Dialog> <NodeDetail open={modalOpen} onClose={handleModalClose} onSubmit={handleModalSubmit} formData={formData} onChange={handleInputChange} /> </>
)
}
assolutamente sì! aggiungiamo un bottone “Auto layout” che, quando cliccato, riordina i nodi in un diagramma “a scala” come al primo render:
logic
: i figli si dispongono in orizzontale attorno al padre, alla riga sottostart
, prende i nodi con indegree 0)nodesData
Di seguito solo gli insert minimi.
autoLayout
(mettila dentro il componente, vicino alle altre handlers)tsxconst autoLayout = () => { const graph = graphRef.current if (!graph) return type N = any const list = (nodesData as N[]) || [] if (!list.length) return // Helpers const getChildren = (n: N): string[] => { const out: string[] = [] if (n?.next) out.push(n.next) if (n?.type === 'logic' && Array.isArray(n?.data?.paths)) { n.data.paths.forEach((p: any) => { if (p?.next) out.push(p.next) }) } return out } const byId = new Map<string, N>() const indeg: Record<string, number> = {} list.forEach(n => { byId.set(n.id, n); indeg[n.id] = 0 }) list.forEach(n => getChildren(n).forEach(c => { if (c in indeg) indeg[c] += 1 })) const roots = list .filter(n => n.id === 'start' || indeg[n.id] === 0) .sort((a, b) => (a.id === 'start' ? -1 : b.id === 'start' ? 1 : 0)) // Parametri layout (coerenti con l'inizializzazione) const yStart = 80 const verticalSpacing = 180 const horizontalSpacing = 320 const container = graph.container as HTMLDivElement const containerWidth = container?.getBoundingClientRect?.().width ?? 1200 const xCenter = Math.max(400, Math.floor(containerWidth / 2)) const pos = new Map<string, { x: number; y: number }>() const placed = new Set<string>() const place = (id: string, layer: number, xHint: number) => { if (pos.has(id)) return pos.get(id)! const p = { x: xHint, y: yStart + layer * verticalSpacing } pos.set(id, p) placed.add(id) return p } // posiziona radici (se più di una, scalale in orizzontale) roots.forEach((r, i) => { const x = xCenter + (i - (roots.length - 1) / 2) * horizontalSpacing place(r.id, 0, x) }) // Visita BFS per costruire livelli a "scala" const q: Array<{ id: string; layer: number }> = roots.map(r => ({ id: r.id, layer: 0 })) const seen = new Set<string>(roots.map(r => r.id)) while (q.length) { const { id, layer } = q.shift()! const node = byId.get(id) if (!node) continue const here = pos.get(id) || place(id, layer, xCenter) const children = getChildren(node) if (node.type === 'logic' && children.length > 0) { // distribuisci i figli in orizzontale children.forEach((cid, i) => { const childX = here.x + (i - (children.length - 1) / 2) * horizontalSpacing if (!pos.has(cid)) place(cid, layer + 1, childX) }) } else if (children.length === 1) { const cid = children[0] if (!pos.has(cid)) place(cid, layer + 1, here.x) // stessa X per “scala” } else if (children.length > 1) { // caso raro: non-logic con più figli -> comportati come logic children.forEach((cid, i) => { const childX = here.x + (i - (children.length - 1) / 2) * horizontalSpacing if (!pos.has(cid)) place(cid, layer + 1, childX) }) } children.forEach(cid => { if (!seen.has(cid)) { seen.add(cid); q.push({ id: cid, layer: layer + 1 }) } }) } // Applica posizioni ai nodi del grafo graph.getNodes().forEach((cell) => { const p = pos.get(cell.id) if (p) { cell.setPosition(p.x, p.y) } }) // Aggiorna lo stato nodesData con le nuove posizioni setNodesData(prev => (prev as N[]).map(n => (pos.has(n.id) ? { ...n, position: { ...pos.get(n.id)! } } : n)) as any ) // Centra e sistema lo zoom graph.zoomTo(1, { absolute: true }) graph.centerContent() }
Nel tuo <DialogHeader ...>
aggiungi un pulsante accanto alla X. Ad esempio:
tsx<DialogHeader className='flex justify-between items-center'> <div className='flex items-center gap-3 w-full'> <span className='text-lg font-semibold'>Workflow Detail</span> <div className='ml-auto flex items-center gap-2'> <button onClick={autoLayout} className="rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 active:scale-[0.99]" title="Riordina in diagramma a scala" > Auto layout </button> <svg onClick={() => handleOpen()} className='cursor-pointer' width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' > <path d='M13.4115 12L18.6952 6.71628...Z' fill='black'></path> </svg> </div> </div> </DialogHeader>
Se preferisci tener fermo il layout della search che hai già messo, puoi semplicemente infilare il bottone accanto alla lente, usando le stesse classi Tailwind del tuo header.
Se ti piace un micro-feedback, puoi colorare temporaneamente il bordo dei nodi dopo il layout:
tsxgraph.getNodes().forEach((cell) => { const data = (cell.getData?.() as any) || {} const original = cell.getAttrs() cell.setAttrs({ body: { ...(original?.body || {}), stroke: '#0ea5e9', strokeWidth: 3 } }) setTimeout(() => cell.setAttrs(original || {}), 500) })
Inseriscilo in fondo ad autoLayout
dopo aver applicato le posizioni.
Fatto! Con questo, cliccando “Auto layout” i nodi si riallineano a scala come nel caricamento iniziale, con i rami dei logic
distribuiti orizzontalmente e la vista recentrata. Vuoi anche un toggle per “compattare” i rami (riducendo horizontalSpacing
dinamicamente)? Posso aggiungere un parametro o un secondo bottone (Compact / Spacious).