nella parte dell'header dove c'è workflowDetail a ...

Criado em: 14 de agosto de 2025

Respondido usando GPT-5 por Chat01

Pergunta

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) }} > &times; </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} /> </> ) }

Pensamento

Implementing search functionality in React

It sounds like the user wants to add a search bar in the header to filter and zoom into specific nodes within a graph using React and X6. I see they already have input handling and node filtering in place. I'll provide code that builds upon this, adding the search snippet to filter nodes by name and description, and zoom into the selected node using graphRef.current.centerCell.

Planning node focus function

I’m considering creating a function focusNode(nodeId) to zoom in on a specific node and center it using X6's methods. I’ll use graph.centerCell(node) and graph.zoomTo to zoom and center the node, ensuring it fits the viewport. To handle the state for the search query, I’ll update the nodes data, adding a list in the header UI for filtering.

Creating search input for the header

I need to implement a search input in the header, likely as a dropdown. First, I’ll create a state for searchText and use nodesData to filter nodes by name and description without calling getNodes on each render. For node selection, I'll focus the node and highlight it briefly with animation. Then, I'll update JSX for the search, debounce the input, and ensure everything looks good with minimal CSS.

Thought for 36s

Resposta

ok! aggiungiamo una “search” nell’header (a sinistra del titolo) che:

  • filtra i nodi presenti nel grafo per name e description
  • mostra una listina cliccabile
  • al click centra e fa zoom su quel nodo (con un piccolo highlight temporaneo)

Qui sotto ti lascio solo gli insert minimi da copiare: 4 step rapidi.


1) Stato & utilità (in alto al file)

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):

tsx
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])

2) UI nell’header (a sinistra del titolo)

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).


3) Chiudi il menu quando clicchi fuori (facoltativo ma utile)

Aggiungi un piccolo handler globale (opzionale):

tsx
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) }, [])

E nel markup dell’input avvolgi il container con className="header-search":

tsx
<div className="relative w-full header-search"> {/* ...input + lista... */} </div>

4) (Opzionale) scorciatoie tastiera

Se vuoi, puoi permettere “Enter” per il primo risultato:

tsx
const 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}.


Perché così?

  • Semplice: riutilizzi nodesData come fonte verità (già sincronizzata con X6 quando muovi/editi).
  • Robusto: focusNode calcola uno zoom naturale per il nodo e lo centra, senza plugin extra.
  • UX: mini highlight verde per orientarsi dopo il jump.

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.

Pergunta

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

text
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)

}

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

text
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

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

text
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()

text
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)

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

)
}

Pensamento

Planning layout adjustment algorithm

I need to create an algorithm to reorder nodes and edges into a stair-step layout, similar to initial loading. This requires using BFS or topological sorting for node positioning with spacing. Should also implement an autoLayout() function for recalculating positions and zoom fit. It'll need a button to trigger the layout change.

Designing graph layout

I need to determine root nodes by checking for ones with no incoming edges or ID 'start'. I'll create a graph using BFS to produce layers of nodes. For logic nodes, children will move horizontally to the next layer. I'll use a layout with fixed parameters for node positioning and spacing.

Finalizing node layout algorithm

I’m considering a more general approach: placing each layer’s nodes sequentially with horizontal spacing, but since the request is for a scaffold-like layout, I’ll stick to stacking vertically and centering child nodes horizontally around their logic parent. I’ll use BFS for positioning and set the layout for each node after calculating positions.

Implementing auto-layout button

I’ll keep things simple by using node.setPosition(x, y) and avoid complicated animations. For updating node positions, I’ll make sure to modify the nodesData map accordingly. The auto-layout button will be added to the header, triggering the autoLayout function without affecting existing edge connections.

Thought for 41s

Resposta

assolutamente sì! aggiungiamo un bottone “Auto layout” che, quando cliccato, riordina i nodi in un diagramma “a scala” come al primo render:

  • catena lineare: i figli stanno sotto, stessa X del padre
  • nodo logic: i figli si dispongono in orizzontale attorno al padre, alla riga sotto
  • supporta più radici (se non c’è start, prende i nodi con indegree 0)
  • aggiorna sia la posizione nel grafo che nel tuo nodesData
  • alla fine fa centerContent per inquadrare tutto

Di seguito solo gli insert minimi.


1) Aggiungi la funzione autoLayout (mettila dentro il componente, vicino alle altre handlers)

tsx
const 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() }

2) Aggiungi il bottone nell’header

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.


3) (Opzionale) highlight rapido durante il riordino

Se ti piace un micro-feedback, puoi colorare temporaneamente il bordo dei nodi dopo il layout:

tsx
graph.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).

Compartilhar esta Q&A