770 lines
27 KiB
TypeScript
770 lines
27 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 { 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<string, unknown>;
|
||
}
|
||
|
||
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<FlowCanvasProps> = ({
|
||
nodes,
|
||
connections,
|
||
nodeTypes,
|
||
onNodesChange,
|
||
onConnectionsChange,
|
||
onDropNodeType,
|
||
getLabel,
|
||
getCategoryIcon,
|
||
onSelectionChange,
|
||
}) => {
|
||
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 [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 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<string>();
|
||
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<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} Nodes ausgewählt • <kbd>Entf</kbd> zum Löschen • Ziehen zum Verschieben • <kbd>Shift</kbd>+Klick zum Hinzufügen/Entfernen
|
||
</div>
|
||
)}
|
||
{connectingFrom && !selectedConnectionId && (
|
||
<div className={styles.connectionHint}>
|
||
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang • <kbd>Esc</kbd> zum Abbrechen
|
||
</div>
|
||
)}
|
||
{selectedConnectionId && (
|
||
<div className={styles.connectionHint}>
|
||
Pfeil ausgewählt • <kbd>Entf</kbd> zum Löschen • Klicken Sie auf einen anderen Eingang 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>
|
||
</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 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 (
|
||
<g
|
||
key={c.id}
|
||
onClick={(e) => 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)"
|
||
>
|
||
<path
|
||
d={pathD}
|
||
fill="none"
|
||
stroke="transparent"
|
||
strokeWidth="16"
|
||
pointerEvents="stroke"
|
||
/>
|
||
<path
|
||
d={pathD}
|
||
fill="none"
|
||
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'}
|
||
strokeWidth={isSelected ? 3 : 2}
|
||
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
|
||
pointerEvents="none"
|
||
/>
|
||
</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 isSelected = selectedNodeIds.has(node.id);
|
||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||
|
||
return (
|
||
<div
|
||
key={node.id}
|
||
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
|
||
style={{
|
||
left: node.x,
|
||
top: node.y,
|
||
width: NODE_WIDTH,
|
||
height: NODE_HEIGHT,
|
||
borderColor: color,
|
||
backgroundColor: `${color}15`,
|
||
}}
|
||
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);
|
||
}}
|
||
>
|
||
{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 (
|
||
<div
|
||
key={index}
|
||
className={styles.handleWrapper}
|
||
style={{
|
||
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
||
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
||
top: pos.y - node.y - HANDLE_OFFSET,
|
||
}}
|
||
>
|
||
{outputLabel && pos.side === 'right' && (
|
||
<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
|
||
? 'Aktuelles Ziel – klicken zum Abwählen'
|
||
: 'Klicken zum Umleiten'
|
||
: undefined)
|
||
}
|
||
/>
|
||
{outputLabel && pos.side === 'left' && (
|
||
<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>
|
||
)}
|
||
</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>Nodes aus der Liste links hierher ziehen.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|