/** * 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 { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { AiBadge } from '../nodes/shared/AiBadge'; 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; inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; } 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; const LAYOUT_V_GAP = 80; const LAYOUT_H_GAP = 60; /** * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers. * Disconnected nodes are appended as extra roots. */ export function computeAutoLayout( nodes: CanvasNode[], connections: CanvasConnection[], ): CanvasNode[] { if (nodes.length === 0) return nodes; const inDegree = new Map(); const children = new Map(); for (const n of nodes) { inDegree.set(n.id, 0); children.set(n.id, []); } for (const c of connections) { inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1); children.get(c.sourceId)?.push(c.targetId); } const layers: string[][] = []; const layerOf = new Map(); const queue: string[] = []; for (const n of nodes) { if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id); } while (queue.length > 0) { const batch: string[] = [...queue]; queue.length = 0; const layerIdx = layers.length; layers.push(batch); for (const id of batch) { layerOf.set(id, layerIdx); for (const childId of children.get(id) ?? []) { const deg = (inDegree.get(childId) ?? 1) - 1; inDegree.set(childId, deg); if (deg === 0) queue.push(childId); } } } const placed = new Set(layerOf.keys()); for (const n of nodes) { if (!placed.has(n.id)) { const layerIdx = layers.length; layers.push([n.id]); layerOf.set(n.id, layerIdx); } } const startX = 40; const startY = 40; return nodes.map((n) => { const layer = layerOf.get(n.id) ?? 0; const siblings = layers[layer]; const idxInLayer = siblings.indexOf(n.id); return { ...n, x: startX + idxInLayer * (NODE_WIDTH + LAYOUT_H_GAP), y: startY + layer * (NODE_HEIGHT + LAYOUT_V_GAP), }; }); } function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string { if (typeof schema === 'string') return schema; if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload'; return ''; } /** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */ function _checkConnectionCompatibility( sourceNode: CanvasNode, sourceOutputIdx: number, targetNode: CanvasNode, targetInputIdx: number, nodeTypes: NodeType[], ): 'ok' | 'warning' { const srcType = nodeTypes.find((nt) => nt.id === sourceNode.type); const tgtType = nodeTypes.find((nt) => nt.id === targetNode.type); if (!srcType?.outputPorts || !tgtType?.inputPorts) return 'ok'; const srcPort = srcType.outputPorts[sourceOutputIdx]; const tgtPort = tgtType.inputPorts[targetInputIdx]; if (!srcPort || !tgtPort) return 'ok'; const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef); const accepts = tgtPort.accepts; if (!accepts || accepts.length === 0) return 'ok'; if (accepts.includes('Transit')) return 'ok'; if (srcSchema && accepts.includes(srcSchema)) return 'ok'; if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok'; return 'warning'; } 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; highlightedNodeIds?: Record; /** Phase-4: per-node "required-but-unbound" param errors. The canvas renders * a red error badge in the top-right of each node whose id is a key. */ nodeErrors?: Record>; /** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt * (z. B. ``application/json+workflow`` aus der UDB-FilesTab), * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; } const HIGHLIGHT_COLORS: Record = { running: '#f0ad4e', completed: '#28a745', failed: '#dc3545', skipped: '#6c757d', }; export const FlowCanvas: React.FC = ({ nodes, connections, nodeTypes, onNodesChange, onConnectionsChange, onDropNodeType, getLabel, getCategoryIcon, onSelectionChange, highlightedNodeIds, nodeErrors, onExternalDrop, }) => { const { t } = useLanguage(); const containerRef = useRef(null); const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [connectionWarnings, setConnectionWarnings] = useState>({}); 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 centerX = node.x + w / 2; if (isOutput) { if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' }; const step = w / (ioCount + 1); return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' }; } else { if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; const step = w / (ioCount + 1); return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' }; } }, [] ); const getUsedTargetHandles = useMemo(() => { const used = new Set(); connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`)); return used; }, [connections]); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) if (onExternalDrop) { const reservedMimes = new Set([ 'application/json', 'application/tree-items', 'application/file-id', 'application/file-ids', 'application/folder-id', ]); for (const mime of Array.from(e.dataTransfer.types)) { if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue; const raw = e.dataTransfer.getData(mime); if (!raw) continue; try { const payload = JSON.parse(raw); const handled = await onExternalDrop(mime, payload); if (handled) return; } catch { // andere Drag-Source → ignorieren, Standard-Pfad versuchen } } } // 2) Standard: Node-Type aus der NodeSidebar 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, onExternalDrop, 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, }; const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId); const tgtNode = nodes.find((n) => n.id === targetNodeId); if (srcNode && tgtNode) { const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs ? connectingFrom.handleIndex - srcNode.inputs : 0; const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes); if (compat === 'warning') { setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true })); } } 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} {t('Knoten ausgewählt')} {' · '} Entf {t('zum Löschen')} {' · '} {t('Ziehen zum Verschieben')} {' · '} Shift {t('+Klick zum Hinzufügen oder Entfernen')}
)} {connectingFrom && !selectedConnectionId && (
{t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')} {' · '} Esc {t('zum Abbrechen')}
)} {selectedConnectionId && (
{t('Verbindungspfeil ausgewählt')} {' · '} Entf {t('zum Löschen')} {' · '} {t('Anderen Eingang anklicken 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 dy = tgt.y - src.y; const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`; const isSelected = selectedConnectionId === c.id; const isWarning = connectionWarnings[c.id]; const strokeColor = isSelected ? 'var(--primary-color, #007bff)' : isWarning ? '#FF9800' : 'var(--text-secondary, #666)'; return ( handleConnectionClick(e, c.id)} style={{ cursor: 'pointer' }} role="button" tabIndex={-1} aria-label={t('Verbindung auswählen, Entf zum Löschen')} > {isWarning && !isSelected && ( {t('Typeninkompatibilität: Ausgabetyp')} )} ); })} {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 wireSourceNode = connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null; const isSelected = selectedNodeIds.has(node.id); const isEditingTitle = editingNodeId === node.id && editingField === 'title'; const displayTitle = node.title ?? node.label ?? getLabel(node); const hlStatus = highlightedNodeIds?.[node.id]; const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null; 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); }} > {nt?.meta?.usesAi === true && ( )} {nodeErrors?.[node.id]?.length ? (
e.paramLabel).join(', ') } style={{ position: 'absolute', top: -8, right: -8, minWidth: 20, height: 20, borderRadius: 10, padding: '0 6px', background: 'var(--danger-color, #dc3545)', color: '#fff', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.25)', zIndex: 5, pointerEvents: 'auto', }} > {nodeErrors[node.id].length}
) : null} {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; let wireTargetOk = true; if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) { const sourceOutputIdx = connectingFrom.handleIndex >= wireSourceNode.inputs ? connectingFrom.handleIndex - wireSourceNode.inputs : 0; wireTargetOk = _checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok'; } const canConnect = isOutput || (!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) || (!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection)); const nt = nodeTypeMap[node.type]; const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined; return (
{outputLabel && pos.side === 'bottom' && ( {outputLabel} )}
handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} title={ outputLabel ?? (selectedConnectionId && !isOutput ? used ? t('Aktuelles Ziel klicken, um abzuwählen') : t('Klicken zum Umleiten') : undefined) } /> {outputLabel && pos.side === 'top' && ( {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} )} {node.comment && ( {node.comment} )}
{node.comment && (
{node.comment}
)}
); })} {selectionBox && (
)} {nodes.length === 0 && (

{t('Knoten aus der Liste links ziehen')}

)}
); };