frontend_nyla/src/components/FlowEditor/editor/FlowCanvas.tsx
2026-04-25 01:13:13 +02:00

1010 lines
36 KiB
TypeScript

/**
* 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<string, unknown>;
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<string, number>();
const children = new Map<string, string[]>();
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<string, number>();
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<string, string>;
/** 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<string, Array<{ paramName: string; paramLabel: string }>>;
/** 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> | boolean;
}
const HIGHLIGHT_COLORS: Record<string, string> = {
running: '#f0ad4e',
completed: '#28a745',
failed: '#dc3545',
skipped: '#6c757d',
};
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
connections,
nodeTypes,
onNodesChange,
onConnectionsChange,
onDropNodeType,
getLabel,
getCategoryIcon,
onSelectionChange,
highlightedNodeIds,
nodeErrors,
onExternalDrop,
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
const [selectionBox, setSelectionBox] = useState<{
startX: number;
startY: number;
endX: number;
endY: number;
} | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(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<string | null>(null);
const [dragOffset, setDragOffset] = useState({
startClientX: 0,
startClientY: 0,
nodesInitial: {} as Record<string, { x: number; y: number }>,
});
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<string, NodeType> = {};
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<string>();
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<string, { x: number; y: number }> = {};
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<typeof selectionBox>(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<string>();
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<Pick<CanvasNode, 'title' | 'comment'>>) => {
onNodesChange(
nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
);
},
[nodes, onNodesChange]
);
return (
<div
ref={containerRef}
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId ? styles.canvasSelecting : ''}`}
style={{
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
}}
onDragOver={(e) => 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 && (
<div className={styles.connectionHint}>
{selectedNodeIds.size} {t('Knoten ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Ziehen zum Verschieben')}
{' · '}
<kbd>Shift</kbd>
{t('+Klick zum Hinzufügen oder Entfernen')}
</div>
)}
{connectingFrom && !selectedConnectionId && (
<div className={styles.connectionHint}>
{t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')}
{' · '}
<kbd>Esc</kbd> {t('zum Abbrechen')}
</div>
)}
{selectedConnectionId && (
<div className={styles.connectionHint}>
{t('Verbindungspfeil ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Anderen Eingang anklicken zum Umleiten')}
</div>
)}
<div
className={styles.canvasContent}
style={{
width: svgBounds.width,
height: svgBounds.height,
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<svg
className={styles.connectionsLayer}
width={svgBounds.width}
height={svgBounds.height}
style={{ position: 'absolute', left: 0, top: 0 }}
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
</marker>
<marker
id="arrowhead-selected"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
</marker>
<marker
id="arrowhead-warning"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#FF9800" />
</marker>
</defs>
{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 (
<g
key={c.id}
onClick={(e) => handleConnectionClick(e, c.id)}
style={{ cursor: 'pointer' }}
role="button"
tabIndex={-1}
aria-label={t('Verbindung auswählen, Entf zum Löschen')}
>
<path
d={pathD}
fill="none"
stroke="transparent"
strokeWidth="16"
pointerEvents="stroke"
/>
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth={isSelected ? 3 : 2}
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
pointerEvents="none"
/>
{isWarning && !isSelected && (
<title>{t('Typeninkompatibilität: Ausgabetyp')}</title>
)}
</g>
);
})}
{connectingFrom && dragPos && (() => {
const end = clientToCanvas(dragPos.x, dragPos.y);
return (
<path
d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
fill="none"
stroke="var(--primary-color, #007bff)"
strokeWidth="2"
strokeDasharray="4 4"
pointerEvents="none"
/>
);
})()}
</svg>
{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 (
<div
key={node.id}
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
style={{
left: node.x,
top: node.y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
borderColor: hlColor || color,
backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
}}
onClick={(e) => 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 && (
<AiBadge
variant="canvas"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{nodeErrors?.[node.id]?.length ? (
<div
role="status"
title={
t('Pflicht-Felder ohne Quelle: ') +
nodeErrors[node.id].map((e) => 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}
</div>
) : 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 (
<div
key={index}
className={styles.handleWrapper}
style={{
top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
left: pos.x - node.x - HANDLE_OFFSET,
}}
>
{outputLabel && pos.side === 'bottom' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
<div
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
onMouseDown={(e) => 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' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
</div>
);
})}
<div className={styles.canvasNodeContent}>
<div
className={styles.canvasNodeIcon}
style={{ backgroundColor: `${color}40`, color }}
>
{getCategoryIcon(category)}
</div>
<div className={styles.canvasNodeText}>
{isEditingTitle ? (
<input
type="text"
className={styles.canvasNodeInput}
value={node.title ?? displayTitle}
autoFocus
onClick={(e) => 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 })}
/>
) : (
<span
className={styles.canvasNodeTitle}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingNodeId(node.id);
setEditingField('title');
}}
>
{displayTitle}
</span>
)}
{node.comment && (
<span className={styles.canvasNodeComment}>{node.comment}</span>
)}
</div>
{node.comment && (
<div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
)}
</div>
</div>
);
})}
{selectionBox && (
<div
className={styles.selectionBox}
style={{
left: Math.min(selectionBox.startX, selectionBox.endX),
top: Math.min(selectionBox.startY, selectionBox.endY),
width: Math.abs(selectionBox.endX - selectionBox.startX),
height: Math.abs(selectionBox.endY - selectionBox.startY),
}}
/>
)}
{nodes.length === 0 && (
<div className={styles.canvasPlaceholder}>
<p>{t('Knoten aus der Liste links ziehen')}</p>
</div>
)}
</div>
</div>
);
};