ui-nyla/src/components/Automation2FlowEditor/editor/FlowCanvas.tsx

770 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
};