/** * FlowCanvas - Workflow graph canvas with nodes and connection lines. * Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NodeType } from '../../../api/automation2Api'; import styles from './Automation2FlowEditor.module.css'; export interface CanvasNode { id: string; type: string; x: number; y: number; label?: string; title?: string; comment?: string; color?: string; inputs: number; outputs: number; parameters?: Record; } export interface CanvasConnection { id: string; sourceId: string; sourceHandle: number; targetId: string; targetHandle: number; } const NODE_WIDTH = 200; const NODE_HEIGHT = 72; const HANDLE_SIZE = 12; const HANDLE_OFFSET = HANDLE_SIZE / 2; interface FlowCanvasProps { nodes: CanvasNode[]; connections: CanvasConnection[]; nodeTypes: NodeType[]; onNodesChange: (nodes: CanvasNode[]) => void; onConnectionsChange: (connections: CanvasConnection[]) => void; onDropNodeType: (nodeTypeId: string, x: number, y: number) => void; getLabel: (node: CanvasNode) => string; getCategoryIcon: (category: string) => React.ReactNode; onSelectionChange?: (node: CanvasNode | null) => void; } export const FlowCanvas: React.FC = ({ nodes, connections, nodeTypes, onNodesChange, onConnectionsChange, onDropNodeType, getLabel, getCategoryIcon, onSelectionChange, }) => { const containerRef = useRef(null); const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; endX: number; endY: number; } | null>(null); const [editingNodeId, setEditingNodeId] = useState(null); const [editingField, setEditingField] = useState<'title' | null>(null); const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; handleIndex: number; x: number; y: number; } | null>(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const [draggingNodeId, setDraggingNodeId] = useState(null); const [dragOffset, setDragOffset] = useState({ startClientX: 0, startClientY: 0, nodesInitial: {} as Record, }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [panning, setPanning] = useState<{ startX: number; startY: number; startPanX: number; startPanY: number; } | null>(null); const nodeTypeMap = useMemo(() => { const m: Record = {}; nodeTypes.forEach((nt) => { m[nt.id] = nt; }); return m; }, [nodeTypes]); useEffect(() => { if (onSelectionChange) { const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; onSelectionChange(node); } }, [selectedNodeId, nodes, onSelectionChange]); const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => { e.stopPropagation(); setSelectedConnectionId(connId); setSelectedNodeIds(new Set()); }, []); const handleDeleteConnection = useCallback(() => { if (!selectedConnectionId) return; onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); setSelectedConnectionId(null); }, [selectedConnectionId, connections, onConnectionsChange]); const getHandlePosition = useCallback( (node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => { const isOutput = handleIndex >= node.inputs; const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex; const ioCount = isOutput ? node.outputs : node.inputs; const w = NODE_WIDTH; const h = NODE_HEIGHT; const centerY = node.y + h / 2; if (isOutput) { if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' }; if (ioCount === 2) { return ioIndex === 0 ? { x: node.x + w, y: node.y + h / 3, side: 'right' } : { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' }; } const step = h / (ioCount + 1); return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' }; } else { if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' }; if (ioCount === 2) { return ioIndex === 0 ? { x: node.x, y: node.y + h / 3, side: 'left' } : { x: node.x, y: node.y + (2 * h) / 3, side: 'left' }; } const step = h / (ioCount + 1); return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' }; } }, [] ); const getUsedTargetHandles = useMemo(() => { const used = new Set(); connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`)); return used; }, [connections]); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const raw = e.dataTransfer.getData('application/json'); if (!raw || !containerRef.current) return; try { const { type } = JSON.parse(raw); const el = containerRef.current; const rect = el.getBoundingClientRect(); const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; onDropNodeType(type, Math.max(0, x), Math.max(0, y)); } catch (_) {} }, [onDropNodeType, panOffset, zoom] ); const handleHandleMouseDown = useCallback( (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => { e.stopPropagation(); if (!isOutput) return; const node = nodes.find((n) => n.id === nodeId); if (!node) return; const pos = getHandlePosition(node, handleIndex); setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y }); setDragPos({ x: e.clientX, y: e.clientY }); }, [nodes, getHandlePosition] ); const handleHandleMouseUp = useCallback( (e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => { e.stopPropagation(); const targetNode = nodes.find((n) => n.id === targetNodeId); if (!targetNode || targetHandleIndex >= targetNode.inputs) return; if (selectedConnectionId) { const sel = connections.find((c) => c.id === selectedConnectionId); if (sel) { const key = `${targetNodeId}-${targetHandleIndex}`; const currentTargetKey = `${sel.targetId}-${sel.targetHandle}`; if (key === currentTargetKey) { setSelectedConnectionId(null); return; } if (getUsedTargetHandles.has(key)) { setSelectedConnectionId(null); return; } onConnectionsChange( connections.map((c) => c.id === selectedConnectionId ? { ...c, targetId: targetNodeId, targetHandle: targetHandleIndex } : c ) ); setSelectedConnectionId(null); } return; } if (!connectingFrom || connectingFrom.nodeId === targetNodeId) { setConnectingFrom(null); setDragPos(null); return; } const key = `${targetNodeId}-${targetHandleIndex}`; if (getUsedTargetHandles.has(key)) { setConnectingFrom(null); setDragPos(null); return; } const newConn: CanvasConnection = { id: `c_${Date.now()}`, sourceId: connectingFrom.nodeId, sourceHandle: connectingFrom.handleIndex, targetId: targetNodeId, targetHandle: targetHandleIndex, }; onConnectionsChange([...connections, newConn]); setConnectingFrom(null); setDragPos(null); }, [connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId] ); React.useEffect(() => { if (!connectingFrom || !dragPos) return; const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY }); const onUp = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest(`.${styles.handleOutput}`)) { return; } if (target.closest(`.${styles.handleInput}`)) { return; } setConnectingFrom(null); setDragPos(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [connectingFrom, dragPos]); const handleNodeMouseDown = useCallback( (e: React.MouseEvent, nodeId: string) => { const node = nodes.find((n) => n.id === nodeId); if (!node) return; const idsToMove = selectedNodeIds.has(nodeId) ? selectedNodeIds : new Set([nodeId]); if (!selectedNodeIds.has(nodeId)) { setSelectedNodeIds(idsToMove); } const nodesInitial: Record = {}; nodes.forEach((n) => { if (idsToMove.has(n.id)) nodesInitial[n.id] = { x: n.x, y: n.y }; }); setDraggingNodeId(nodeId); setDragOffset({ startClientX: e.clientX, startClientY: e.clientY, nodesInitial, }); }, [nodes, selectedNodeIds] ); React.useEffect(() => { if (!draggingNodeId) return; const onMove = (e: MouseEvent) => { const dx = (e.clientX - dragOffset.startClientX) / zoom; const dy = (e.clientY - dragOffset.startClientY) / zoom; onNodesChange( nodes.map((n) => { const init = dragOffset.nodesInitial[n.id]; if (!init) return n; return { ...n, x: init.x + dx, y: init.y + dy }; }) ); }; const onUp = () => setDraggingNodeId(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]); const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 }); React.useEffect(() => { const el = containerRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); setContainerBounds({ left: r.left, top: r.top }); }; update(); window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); }, []); const clientToCanvas = useCallback( (clientX: number, clientY: number) => ({ x: (clientX - containerBounds.left - panOffset.x) / zoom, y: (clientY - containerBounds.top - panOffset.y) / zoom, }), [containerBounds, panOffset, zoom] ); const handleCanvasMouseDown = useCallback( (e: React.MouseEvent) => { const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`); if (hitNode || connectingFrom) return; if (e.shiftKey) { e.preventDefault(); const pt = clientToCanvas(e.clientX, e.clientY); setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y }); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); } else { setPanning({ startX: e.clientX, startY: e.clientY, startPanX: panOffset.x, startPanY: panOffset.y, }); } }, [connectingFrom, panOffset, clientToCanvas] ); const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; setZoom((z) => Math.min(2, Math.max(0.25, z + delta))); }, []); React.useEffect(() => { const el = containerRef.current; if (!el) return; el.addEventListener('wheel', handleWheel, { passive: false }); return () => el.removeEventListener('wheel', handleWheel); }, [handleWheel]); React.useEffect(() => { if (!panning) return; const onMove = (e: MouseEvent) => { setPanOffset({ x: panning.startPanX + (e.clientX - panning.startX), y: panning.startPanY + (e.clientY - panning.startY), }); }; const onUp = () => setPanning(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [panning]); const selectionBoxRef = useRef(null); const marqueeJustEndedRef = useRef(false); selectionBoxRef.current = selectionBox; React.useEffect(() => { if (!selectionBox) return; const onMove = (e: MouseEvent) => { const pt = clientToCanvas(e.clientX, e.clientY); setSelectionBox((prev) => prev ? { ...prev, endX: pt.x, endY: pt.y } : null ); }; const onUp = () => { const box = selectionBoxRef.current; setSelectionBox(null); marqueeJustEndedRef.current = true; if (!box) return; const minX = Math.min(box.startX, box.endX); const maxX = Math.max(box.startX, box.endX); const minY = Math.min(box.startY, box.endY); const maxY = Math.max(box.startY, box.endY); const ids = new Set(); nodes.forEach((n) => { const nx = n.x; const ny = n.y; const nRight = nx + NODE_WIDTH; const nBottom = ny + NODE_HEIGHT; const overlaps = nx < maxX && nRight > minX && ny < maxY && nBottom > minY; if (overlaps) ids.add(n.id); }); setSelectedNodeIds(ids); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [selectionBox, nodes, clientToCanvas]); const CANVAS_SIZE = 8000; const svgBounds = useMemo(() => { if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE }; let maxX = 0, maxY = 0; nodes.forEach((n) => { maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200); }); return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) }; }, [nodes]); const handleDeleteNode = useCallback(() => { if (selectedNodeIds.size === 0) return; const ids = selectedNodeIds; onNodesChange(nodes.filter((n) => !ids.has(n.id))); onConnectionsChange( connections.filter( (c) => !ids.has(c.sourceId) && !ids.has(c.targetId) ) ); setSelectedNodeIds(new Set()); setEditingNodeId(null); setEditingField(null); }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]); React.useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; if (e.key === 'Escape') { setConnectingFrom(null); setDragPos(null); setSelectedConnectionId(null); } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedConnectionId) { e.preventDefault(); handleDeleteConnection(); } else if (selectedNodeIds.size > 0) { e.preventDefault(); handleDeleteNode(); } } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [handleDeleteNode, handleDeleteConnection, selectedNodeIds.size, selectedConnectionId]); const handleNodeUpdate = useCallback( (nodeId: string, updates: Partial>) => { onNodesChange( nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n)) ); }, [nodes, onNodesChange] ); return (
e.preventDefault()} onDrop={handleDrop} onMouseDown={handleCanvasMouseDown} tabIndex={0} onClick={() => { if (marqueeJustEndedRef.current) { marqueeJustEndedRef.current = false; return; } setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setConnectingFrom(null); setDragPos(null); }} > {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
{selectedNodeIds.size} Nodes ausgewählt • Entf zum Löschen • Ziehen zum Verschieben • Shift+Klick zum Hinzufügen/Entfernen
)} {connectingFrom && !selectedConnectionId && (
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang • Esc zum Abbrechen
)} {selectedConnectionId && (
Pfeil ausgewählt • Entf zum Löschen • Klicken Sie auf einen anderen Eingang zum Umleiten
)}
{connections.map((c) => { const srcNode = nodes.find((n) => n.id === c.sourceId); const tgtNode = nodes.find((n) => n.id === c.targetId); if (!srcNode || !tgtNode) return null; const src = getHandlePosition(srcNode, c.sourceHandle); const tgt = getHandlePosition(tgtNode, c.targetHandle); const dx = tgt.x - src.x; const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`; const isSelected = selectedConnectionId === c.id; return ( handleConnectionClick(e, c.id)} style={{ cursor: 'pointer' }} role="button" tabIndex={-1} aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)" > ); })} {connectingFrom && dragPos && (() => { const end = clientToCanvas(dragPos.x, dragPos.y); return ( ); })()} {nodes.map((node) => { const nt = nodeTypeMap[node.type]; const category = nt?.category ?? 'io'; const color = node.color ?? nt?.meta?.color ?? '#00BCD4'; const handles: Array<{ index: number; isOutput: boolean }> = []; for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false }); for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true }); const isSelected = selectedNodeIds.has(node.id); const isEditingTitle = editingNodeId === node.id && editingField === 'title'; const displayTitle = node.title ?? node.label ?? getLabel(node); return (
e.stopPropagation()} onMouseDown={(e) => { e.stopPropagation(); setSelectedConnectionId(null); if (e.shiftKey) { setSelectedNodeIds((prev) => { const next = new Set(prev); if (next.has(node.id)) next.delete(node.id); else next.add(node.id); return next; }); return; } if (!selectedNodeIds.has(node.id)) { setSelectedNodeIds(new Set([node.id])); } handleNodeMouseDown(e, node.id); }} > {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null; const isCurrentTargetOfSelection = selConn && selConn.targetId === node.id && selConn.targetHandle === index; const canConnect = isOutput || (!used && connectingFrom) || (!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection)); const nt = nodeTypeMap[node.type]; const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined; return (
{outputLabel && pos.side === 'right' && ( {outputLabel} )}
handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} title={ outputLabel ?? (selectedConnectionId && !isOutput ? used ? 'Aktuelles Ziel – klicken zum Abwählen' : 'Klicken zum Umleiten' : undefined) } /> {outputLabel && pos.side === 'left' && ( {outputLabel} )}
); })}
{getCategoryIcon(category)}
{isEditingTitle ? ( e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') { handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value }); setEditingNodeId(null); setEditingField(null); } if (e.key === 'Escape') { setEditingNodeId(null); setEditingField(null); } }} onBlur={(e) => { handleNodeUpdate(node.id, { title: e.target.value }); setEditingNodeId(null); setEditingField(null); }} onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })} /> ) : ( { e.stopPropagation(); setEditingNodeId(node.id); setEditingField('title'); }} > {displayTitle} )}
); })} {selectionBox && (
)} {nodes.length === 0 && (

Nodes aus der Liste links hierher ziehen.

)}
); };