1010 lines
36 KiB
TypeScript
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>
|
|
);
|
|
};
|