/** * 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, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { AiBadge } from '../nodes/shared/AiBadge'; export interface CanvasNode { id: string; type: string; x: number; y: number; label?: string; title?: string; comment?: string; color?: string; inputs: number; outputs: number; parameters?: Record; inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; } export interface CanvasConnection { id: string; sourceId: string; sourceHandle: number; targetId: string; targetHandle: number; } /** Freie Benutzer-Notiz auf der Canvas; wird nicht in den Workflow/Graph persistiert. */ export interface CanvasStickyNote { id: string; x: number; y: number; width: number; /** Höhe des Textbereichs unter der Toolbar (Pixel). Standard: ``STICKY_NOTE_DEFAULT_HEIGHT``. */ height?: number; text: string; /** Farbe aus ``STICKY_NOTE_PALETTE`` (Standard: ``yellow``). */ colorId?: string; } const STICKY_NOTE_DEFAULT_WIDTH = 220; export const STICKY_NOTE_DEFAULT_HEIGHT = 96; const STICKY_NOTE_MIN_WIDTH = 120; const STICKY_NOTE_MIN_HEIGHT = 48; export const STICKY_NOTE_DEFAULT_COLOR_ID = 'yellow'; /** Vorgaben für Sticky-Hintergrund/-Rand (wie klassische Haftnotizen). */ export const STICKY_NOTE_PALETTE: ReadonlyArray<{ id: string; bg: string; border: string; textareaBg: string; }> = [ { id: 'yellow', bg: 'rgba(255, 249, 196, 0.92)', border: 'rgba(180, 170, 90, 0.55)', textareaBg: 'rgba(255, 252, 220, 0.98)', }, { id: 'pink', bg: 'rgba(255, 228, 238, 0.92)', border: 'rgba(200, 120, 150, 0.55)', textareaBg: 'rgba(255, 240, 245, 0.98)', }, { id: 'mint', bg: 'rgba(220, 248, 230, 0.92)', border: 'rgba(100, 160, 110, 0.5)', textareaBg: 'rgba(235, 252, 238, 0.98)', }, { id: 'sky', bg: 'rgba(220, 236, 255, 0.92)', border: 'rgba(100, 140, 200, 0.5)', textareaBg: 'rgba(235, 244, 255, 0.98)', }, { id: 'lavender', bg: 'rgba(235, 228, 255, 0.92)', border: 'rgba(140, 120, 200, 0.5)', textareaBg: 'rgba(245, 240, 255, 0.98)', }, { id: 'peach', bg: 'rgba(255, 236, 210, 0.92)', border: 'rgba(200, 140, 90, 0.5)', textareaBg: 'rgba(255, 245, 228, 0.98)', }, ]; export function getStickyNotePaletteEntry(colorId?: string) { const id = colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID; return STICKY_NOTE_PALETTE.find((p) => p.id === id) ?? STICKY_NOTE_PALETTE[0]; } const NODE_WIDTH = 200; const NODE_HEIGHT = 72; export const FLOW_CANVAS_MIN_ZOOM = 0.25; export const FLOW_CANVAS_MAX_ZOOM = 4; export interface FlowCanvasViewportEditState { zoom: number; selectedNodeCount: number; connectionSelected: boolean; /** Canvas-Sticky-/Kommentarnote ausgewählt (nicht Workflow-Knoten). */ stickyNoteSelected: boolean; } export type FlowCanvasHandle = { focusCanvas: () => void; zoomIn: () => void; zoomOut: () => void; setZoomPercent: (percent: number) => void; fitWindow: () => void; resetView: () => void; deleteSelection: () => void; duplicateSingleSelection: () => void; toggleConnectionTool: () => void; /** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */ addCanvasComment: () => void; /** Raster-Anordnung: verschachtelte Rangpfade (4.1 / 4.2 …); Haftnotizen unberührt. */ arrangeNodes: () => void; }; const HANDLE_SIZE = 12; const HANDLE_OFFSET = HANDLE_SIZE / 2; const LAYOUT_V_GAP = 80; const LAYOUT_H_GAP = 60; /** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */ function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean { if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false; if (c.sourceId === c.targetId) return true; return srcNode.y > tgtNode.y + 4; } /** Für Layout-Schichtung: Graph ohne Loop-Rückkopplung (Sonst Pflicht-Schichtung senkrecht). */ function stripLoopFeedbackConnections(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasConnection[] { const byId = new Map(nodes.map((n) => [n.id, n])); return connections.filter((c) => { const src = byId.get(c.sourceId); const tgt = byId.get(c.targetId); if (!src || !tgt) return false; return !isLoopFeedbackEdge(c, src, tgt); }); } /** Reihenfolge links→rechts bei Kanten nur zwischen Knoten **dieser** Zeile (DAG/Kahn). */ function orderRowByIntraRowTopo(row: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] { const idSet = new Set(row.map((n) => n.id)); const nodeById = new Map(row.map((n) => [n.id, n])); const inDeg = new Map(); const outs = new Map(); for (const id of idSet) { inDeg.set(id, 0); outs.set(id, []); } for (const c of connections) { if (!idSet.has(c.sourceId) || !idSet.has(c.targetId)) continue; inDeg.set(c.targetId, (inDeg.get(c.targetId) ?? 0) + 1); outs.get(c.sourceId)!.push(c.targetId); } const ready = row .filter((n) => (inDeg.get(n.id) ?? 0) === 0) .sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); const result: CanvasNode[] = []; const q = [...ready]; while (q.length > 0) { q.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); const n = q.shift()!; result.push(n); for (const t of outs.get(n.id) ?? []) { if (!idSet.has(t)) continue; inDeg.set(t, (inDeg.get(t) ?? 1) - 1); if (inDeg.get(t) === 0) q.push(nodeById.get(t)!); } } const placed = new Set(result.map((r) => r.id)); const rest = row.filter((n) => !placed.has(n.id)).sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); return [...result, ...rest]; } /** Verschachtelte Rasterposition ``[4,1]``, ``[4,2]``, ``[4,1,1]`` … für Zeilen vs. Spalten. */ function linearNestedChildPath(p: number[]): number[] { if (p.length === 1) return [p[0] + 1]; return [Math.max(...p) + 1]; } /** Zweig unter einem linearen Knoten ``[n]`` → ``[n+1,k]``, unter verschachteltem Pfad → ``[…p,k]``. */ function branchNestedChildPath(p: number[], branchIndex: number): number[] { if (p.length === 1) return [p[0] + 1, branchIndex]; return [...p, branchIndex]; } function longestPathDepthDown( nodeId: string, outgoingTargets: Map, memo: Map, visiting: Set, ): number { if (memo.has(nodeId)) return memo.get(nodeId)!; if (visiting.has(nodeId)) return 0; visiting.add(nodeId); const outs = outgoingTargets.get(nodeId) ?? []; let best = 0; for (const t of outs) { best = Math.max(best, longestPathDepthDown(t, outgoingTargets, memo, visiting)); } visiting.delete(nodeId); const d = outs.length === 0 ? 0 : 1 + best; memo.set(nodeId, d); return d; } function reachableForwardTargets(start: string, outgoingTargets: Map): Set { const seen = new Set(); const stack = [start]; while (stack.length > 0) { const v = stack.pop()!; if (seen.has(v)) continue; seen.add(v); for (const t of outgoingTargets.get(v) ?? []) stack.push(t); } return seen; } /** Primärbaum: längster Pfad zuerst; Kurzschlüsse (z.B. Trigger→Schleife wenn Trigger→Upload→Schleife) werden nicht verdoppelt. */ function layoutTreeChildren( nodeId: string, outgoingTargets: Map, depthMemo: Map, ): string[] { const outs = outgoingTargets.get(nodeId) ?? []; if (outs.length <= 1) return outs; const scored = outs.map((t) => ({ t, d: longestPathDepthDown(t, outgoingTargets, depthMemo, new Set()), })); scored.sort((a, b) => b.d - a.d || a.t.localeCompare(b.t)); const primary = scored[0].t; const reach = reachableForwardTargets(primary, outgoingTargets); const branchOnly = outs.filter((t) => t !== primary && !reach.has(t)); branchOnly.sort((a, b) => a.localeCompare(b)); return [primary, ...branchOnly]; } function assignNestedRankPaths(nodes: CanvasNode[], stripped: CanvasConnection[]): Map { const nodeIds = new Set(nodes.map((n) => n.id)); const outgoingTargets = new Map(); for (const n of nodes) outgoingTargets.set(n.id, []); const sortedStripped = [...stripped].sort( (a, b) => a.sourceId.localeCompare(b.sourceId) || a.sourceHandle - b.sourceHandle || a.targetId.localeCompare(b.targetId), ); for (const c of sortedStripped) { if (!nodeIds.has(c.sourceId) || !nodeIds.has(c.targetId)) continue; const arr = outgoingTargets.get(c.sourceId)!; if (!arr.includes(c.targetId)) arr.push(c.targetId); } const ranks = new Map(); const depthMemo = new Map(); let nextFallback = 1; function dfs(nodeId: string, path: number[]): void { if (ranks.has(nodeId)) return; ranks.set(nodeId, path); const children = layoutTreeChildren(nodeId, outgoingTargets, depthMemo); if (children.length === 0) return; if (children.length === 1) { dfs(children[0], linearNestedChildPath(path)); return; } children.forEach((ch, idx) => { dfs(ch, branchNestedChildPath(path, idx + 1)); }); } const roots = nodes.filter((n) => !stripped.some((c) => c.targetId === n.id)); roots.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); if (roots.length === 0) { const first = [...nodes].sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)))[0]; if (first) dfs(first.id, [1]); } else { let seq = 1; for (const r of roots) dfs(r.id, [seq++]); } for (const n of nodes) { if (!ranks.has(n.id)) dfs(n.id, [nextFallback++]); } return ranks; } function nestedRankRowGroupKey(path: number[]): string { if (path.length <= 1) return `|L|${path.join('.')}`; return `|B|${path.slice(0, -1).join('.')}`; } function compareNestedRankPathLex(a: number[], b: number[]): number { const n = Math.max(a.length, b.length); for (let i = 0; i < n; i++) { const av = a[i]; const bv = b[i]; if (av === undefined && bv === undefined) return 0; if (av === undefined) return -1; if (bv === undefined) return 1; if (av !== bv) return av - bv; } return 0; } function minNestedRankPath(paths: number[][]): number[] { return paths.reduce((m, p) => (compareNestedRankPathLex(p, m) < 0 ? p : m)); } /** Join-Knoten mit mehreren Vorgängern: einheitliche Zeilen-Stufe ``[max(pred)+1]``. */ function refineConvergenceNestedRanks(nodes: CanvasNode[], stripped: CanvasConnection[], ranks: Map): void { const preds = new Map(); for (const n of nodes) preds.set(n.id, []); for (const c of stripped) { preds.get(c.targetId)!.push(c.sourceId); } const order = topologicalLayersIds(nodes, stripped).flat(); for (const id of order) { const ps = preds.get(id) ?? []; if (ps.length <= 1) continue; let best = 0; for (const p of ps) { const rp = ranks.get(p); if (!rp || rp.length === 0) continue; best = Math.max(best, Math.max(...rp)); } ranks.set(id, [best + 1]); } } /** * Topologische Schichten (Kahn): chronologisch von Quellen zu Senken. * Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup. */ function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] { if (nodes.length === 0) return []; const inDegree = new Map(); const children = new Map(); for (const n of nodes) { inDegree.set(n.id, 0); children.set(n.id, []); } for (const c of connections) { if (!inDegree.has(c.sourceId) || !inDegree.has(c.targetId)) continue; inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1); children.get(c.sourceId)?.push(c.targetId); } const layers: string[][] = []; const layerOf = new Map(); const queue: string[] = []; for (const n of nodes) { if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id); } while (queue.length > 0) { const batch: string[] = [...queue]; queue.length = 0; const layerIdx = layers.length; layers.push(batch); for (const id of batch) { layerOf.set(id, layerIdx); for (const childId of children.get(id) ?? []) { const deg = (inDegree.get(childId) ?? 1) - 1; inDegree.set(childId, deg); if (deg === 0) queue.push(childId); } } } const placed = new Set(layerOf.keys()); for (const n of nodes) { if (!placed.has(n.id)) { layers.push([n.id]); layerOf.set(n.id, layers.length - 1); } } return layers; } /** * 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 layers = topologicalLayersIds(nodes, connections); for (const layer of layers) { layer.sort((a, b) => a.localeCompare(b)); } const layerOf = new Map(); layers.forEach((layer, li) => layer.forEach((id) => layerOf.set(id, li))); 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), }; }); } /** * Raster-Anordnung über **verschachtelte Rangpfade** (z.B. ``4.1`` und ``4.2`` dieselbe Zeile): * DFS auf einem Primärbaum (längster Pfad zuerst, ohne Loop-Rückkopplung für die Schichtung). * Ein-Stufen-Pfade ``[1],[2],[3]`` jeweils eigene Zeile; gemeinsamer Präfix bei Zweigen → gemeinsame Rasterzeile. * Zeilen untereinander unter der Mitte der darüberliegenden Zeile zentriert. */ export function computeGridTidyLayout(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] { if (nodes.length === 0) return nodes; const stripped = stripLoopFeedbackConnections(nodes, connections); const ranks = assignNestedRankPaths(nodes, stripped); refineConvergenceNestedRanks(nodes, stripped, ranks); const rowBuckets = new Map(); for (const n of nodes) { const path = ranks.get(n.id); if (!path) continue; const key = nestedRankRowGroupKey(path); if (!rowBuckets.has(key)) rowBuckets.set(key, []); rowBuckets.get(key)!.push(n); } const rowEntries = [...rowBuckets.values()].map((members) => { const paths = members.map((m) => ranks.get(m.id)!); return { members, rowOrderKey: minNestedRankPath(paths), }; }); rowEntries.sort((a, b) => compareNestedRankPathLex(a.rowOrderKey, b.rowOrderKey)); const rows = rowEntries.map((e) => orderRowByIntraRowTopo( [...e.members].sort((na, nb) => compareNestedRankPathLex(ranks.get(na.id)!, ranks.get(nb.id)!), ), connections, ), ); const rowSpanX = (count: number) => count <= 0 ? 0 : count * NODE_WIDTH + (count - 1) * LAYOUT_H_GAP; const startX = 40; const startY = 40; const out = new Map(); let prevLeft = startX; let prevCount = rows[0]?.length ?? 0; rows.forEach((r, ri) => { const nInRow = r.length; let left: number; if (ri === 0) { left = startX; } else { const centerAbove = prevLeft + rowSpanX(prevCount) / 2; left = centerAbove - rowSpanX(nInRow) / 2; if (left < 8) left = 8; } const y = startY + ri * (NODE_HEIGHT + LAYOUT_V_GAP); r.forEach((n, ci) => { out.set(n.id, { ...n, x: left + ci * (NODE_WIDTH + LAYOUT_H_GAP), y, }); }); prevLeft = left; prevCount = nInRow; }); return nodes.map((n) => out.get(n.id) ?? n); } 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'; } /** flow.loop Eingang 0: Hauptfluss + Schleifen-Rücklauf — mehrere Kanten pro Port. */ function allowsMultipleInboundOnInputPort(targetNode: CanvasNode, targetHandleIndex: number): boolean { return targetNode.type === 'flow.loop' && targetHandleIndex === 0; } const NODE_OBSTACLE_PAD = 12; type Obstacle = { left: number; top: number; right: number; bottom: number }; function obstacleRects(allNodes: CanvasNode[], skipIds: Set, pad: number): Obstacle[] { return allNodes .filter((n) => !skipIds.has(n.id)) .map((n) => ({ left: n.x - pad, top: n.y - pad, right: n.x + NODE_WIDTH + pad, bottom: n.y + NODE_HEIGHT + pad, })); } function pointInObstacle(x: number, y: number, o: Obstacle): boolean { return x >= o.left && x <= o.right && y >= o.top && y <= o.bottom; } function cubicCrossesObstacles( x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, obstacles: Obstacle[], tMargin = 0.08, ): boolean { const steps = 40; for (let i = 1; i < steps; i++) { const t = i / steps; if (t < tMargin || t > 1 - tMargin) continue; const u = 1 - t; const x = u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3; const y = u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3; for (const o of obstacles) { if (pointInObstacle(x, y, o)) return true; } } return false; } /** * Schleifen-Rücklauf — zwei Kubiken, C¹-stetig: * * C1: M sx sy C sx (sy+k) laneX (sy+k) laneX jY * C2: C laneX (tyIn-k) tx (tyIn-k) tx tyIn * * Tangente am Start = (0,+k) → senkrecht RUNTER aus dem Quell-Port ✓ * Tangente am Ende = (0,+k) → senkrecht RUNTER in den Ziel-Port ✓ * Tangente an der Verbindungsstelle (laneX, jY): beide Seiten = (0, (tyIn-sy)/2-k) — gleich → kein Knick ✓ * laneX wird per Sampling solange nach links verschoben, bis keine Kollision vorliegt. */ function feedbackConnectionPathD( src: { x: number; y: number }, tgt: { x: number; y: number }, srcNode: CanvasNode, tgtNode: CanvasNode, allNodes: CanvasNode[], ): string { const sx = src.x; const sy = src.y; const tx = tgt.x; const tyIn = tgt.y - HANDLE_OFFSET; const minNx = allNodes.length ? Math.min(...allNodes.map((n) => n.x)) : Math.min(srcNode.x, tgtNode.x); const vert = Math.max(60, sy - tyIn); const k = Math.min(vert * 0.38, 130); const jY = (sy + tyIn) / 2; const skipIds = srcNode.id === tgtNode.id ? new Set([srcNode.id]) : new Set(); const obstacles = obstacleRects(allNodes, skipIds, NODE_OBSTACLE_PAD); for (let margin = 72; margin <= 640; margin += 24) { const laneX = Math.min(minNx - margin, Math.min(sx, tx) - margin); const ok = !cubicCrossesObstacles(sx, sy, sx, sy + k, laneX, sy + k, laneX, jY, obstacles) && !cubicCrossesObstacles(laneX, jY, laneX, tyIn - k, tx, tyIn - k, tx, tyIn, obstacles); if (ok) { return ( `M ${sx} ${sy}` + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` ); } } const laneX = Math.min(minNx - 640, Math.min(sx, tx) - 640); return ( `M ${sx} ${sy}` + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` ); } function connectionPathD( src: { x: number; y: number }, tgt: { x: number; y: number }, srcNode: CanvasNode, tgtNode: CanvasNode, feedback: boolean, allNodes: CanvasNode[], ): string { if (!feedback) { const dy = tgt.y - src.y; return `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}`; } return feedbackConnectionPathD(src, tgt, srcNode, tgtNode, allNodes); } interface FlowCanvasProps { nodes: CanvasNode[]; connections: CanvasConnection[]; nodeTypes: NodeType[]; onNodesChange: (nodes: CanvasNode[]) => void; onConnectionsChange: (connections: CanvasConnection[]) => void; onDropNodeType: (nodeTypeId: string, x: number, y: number) => void; getLabel: (node: CanvasNode) => string; getCategoryIcon: (category: string) => React.ReactNode; onSelectionChange?: (node: CanvasNode | null) => void; highlightedNodeIds?: Record; /** Phase-4: per-node "required-but-unbound" param errors. The canvas renders * a red error badge in the top-right of each node whose id is a key. */ nodeErrors?: Record>; /** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt * (z. B. ``application/json+workflow`` aus der UDB-FilesTab), * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; onViewportEditState?: (state: FlowCanvasViewportEditState) => void; /** Nach diskreten Canvas-Aktionen (Drop, Drag-Ende, Kante, Löschen …) für Undo. */ onHistoryCheckpoint?: () => void; onConnectionToolActiveChange?: (active: boolean) => void; /** Nur Anzeige: Benutzer-Kommentare auf der Fläche (ohne Workflow-Daten). */ stickyNotes?: CanvasStickyNote[]; onStickyNotesChange?: (notes: CanvasStickyNote[]) => void; } const HIGHLIGHT_COLORS: Record = { running: '#f0ad4e', completed: '#28a745', failed: '#dc3545', skipped: '#6c757d', }; export const FlowCanvas = forwardRef(function FlowCanvas( { nodes, connections, nodeTypes, onNodesChange, onConnectionsChange, onDropNodeType, getLabel, getCategoryIcon, onSelectionChange, highlightedNodeIds, nodeErrors, onExternalDrop, onViewportEditState, onHistoryCheckpoint, onConnectionToolActiveChange, stickyNotes = [], onStickyNotesChange, }, ref ) { const { t } = useLanguage(); const tRef = useRef(t); tRef.current = t; const containerRef = useRef(null); const stickyNotesRef = useRef(stickyNotes); stickyNotesRef.current = stickyNotes; const onStickyNotesChangeRef = useRef(onStickyNotesChange); onStickyNotesChangeRef.current = onStickyNotesChange; const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [connectionWarnings, setConnectionWarnings] = useState>({}); const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; endX: number; endY: number; } | null>(null); const [editingNodeId, setEditingNodeId] = useState(null); const [editingField, setEditingField] = useState<'title' | null>(null); const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; handleIndex: number; x: number; y: number; } | null>(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const [draggingNodeId, setDraggingNodeId] = useState(null); const [dragOffset, setDragOffset] = useState({ startClientX: 0, startClientY: 0, nodesInitial: {} as Record, }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const panZoomRef = useRef({ x: 0, y: 0, zoom: 1 }); panZoomRef.current = { x: panOffset.x, y: panOffset.y, zoom }; const [connectionToolActive, setConnectionToolActive] = useState(false); const [pendingConnClickSource, setPendingConnClickSource] = useState<{ nodeId: string; handleIndex: number; } | null>(null); const [panning, setPanning] = useState<{ startX: number; startY: number; startPanX: number; startPanY: number; } | null>(null); const [editingStickyId, setEditingStickyId] = useState(null); const [stickyFocusSelectAll, setStickyFocusSelectAll] = useState(false); const stickyTextareaRef = useRef(null); const [stickyDragState, setStickyDragState] = useState<{ id: string; startClientX: number; startClientY: number; noteInitial: { x: number; y: number }; } | null>(null); const [stickyResizeState, setStickyResizeState] = useState<{ id: string; startClientX: number; startClientY: number; startWidth: number; startHeight: number; } | null>(null); const [selectedStickyId, setSelectedStickyId] = useState(null); useEffect(() => { if (selectedStickyId && !stickyNotes.some((s) => s.id === selectedStickyId)) { setSelectedStickyId(null); } }, [stickyNotes, selectedStickyId]); const nodeTypeMap = useMemo(() => { const m: Record = {}; nodeTypes.forEach((nt) => { m[nt.id] = nt; }); return m; }, [nodeTypes]); const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); onHistoryCheckpointRef.current = onHistoryCheckpoint; const emitHistoryCheckpoint = useCallback(() => { onHistoryCheckpointRef.current?.(); }, []); useEffect(() => { onViewportEditState?.({ zoom, selectedNodeCount: selectedNodeIds.size, connectionSelected: !!selectedConnectionId, stickyNoteSelected: !!selectedStickyId, }); }, [zoom, selectedNodeIds, selectedConnectionId, selectedStickyId, onViewportEditState]); useEffect(() => { onConnectionToolActiveChange?.(connectionToolActive); }, [connectionToolActive, onConnectionToolActiveChange]); useImperativeHandle( ref, () => ({ focusCanvas: () => { containerRef.current?.focus(); }, zoomIn: () => { setZoom((z) => Math.min(FLOW_CANVAS_MAX_ZOOM, Math.round((z * 1.1 + Number.EPSILON) * 1000) / 1000) ); }, zoomOut: () => { setZoom((z) => Math.max(FLOW_CANVAS_MIN_ZOOM, Math.round((z / 1.1 + Number.EPSILON) * 1000) / 1000) ); }, setZoomPercent: (percent: number) => { const p = Math.min(400, Math.max(25, Number.isFinite(percent) ? percent : 100)); setZoom(p / 100); }, fitWindow: () => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const cw = rect.width; const ch = rect.height; if (nodes.length === 0) { setZoom(1); setPanOffset({ x: 0, y: 0 }); return; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const n of nodes) { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x + NODE_WIDTH); maxY = Math.max(maxY, n.y + NODE_HEIGHT); } const pad = 48; const bw = Math.max(maxX - minX, 1); const bh = Math.max(maxY - minY, 1); const scale = Math.min((cw - 2 * pad) / bw, (ch - 2 * pad) / bh); const newZoom = Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, scale)); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; setZoom(newZoom); setPanOffset({ x: cw / 2 - cx * newZoom, y: ch / 2 - cy * newZoom, }); }, resetView: () => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, deleteSelection: () => { if (selectedConnectionId) { onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); setSelectedConnectionId(null); emitHistoryCheckpoint(); return; } if (selectedNodeIds.size > 0) { 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); setSelectedStickyId(null); emitHistoryCheckpoint(); return; } const changeSticky = onStickyNotesChangeRef.current; const sid = selectedStickyId; if (sid && changeSticky) { changeSticky(stickyNotesRef.current.filter((s) => s.id !== sid)); setSelectedStickyId(null); setEditingStickyId(null); emitHistoryCheckpoint(); } }, duplicateSingleSelection: () => { if (selectedNodeIds.size !== 1) return; const id = [...selectedNodeIds][0]; const node = nodes.find((n) => n.id === id); if (!node) return; const newId = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const clone: CanvasNode = { ...node, id: newId, x: node.x + 40, y: node.y + 40, parameters: node.parameters ? { ...node.parameters } : {}, }; onNodesChange([...nodes, clone]); setSelectedNodeIds(new Set([newId])); setSelectedStickyId(null); emitHistoryCheckpoint(); }, addCanvasComment: () => { const change = onStickyNotesChangeRef.current; if (!change) return; const el = containerRef.current; if (!el) return; const { x: panX, y: panY, zoom: z } = panZoomRef.current; const rect = el.getBoundingClientRect(); const cx = rect.width / 2; const cy = rect.height / 2; const w = STICKY_NOTE_DEFAULT_WIDTH; const canvasX = (cx - panX) / z - w / 2; const canvasY = (cy - panY) / z - 32; const id = `sn_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const text = tRef.current('Kommentar eingeben …'); const note: CanvasStickyNote = { id, x: Math.max(8, canvasX), y: Math.max(8, canvasY), width: w, height: STICKY_NOTE_DEFAULT_HEIGHT, text, colorId: STICKY_NOTE_DEFAULT_COLOR_ID, }; change([...stickyNotesRef.current, note]); setEditingStickyId(id); setSelectedStickyId(id); setStickyFocusSelectAll(true); }, arrangeNodes: () => { if (nodes.length === 0) return; onNodesChange(computeGridTidyLayout(nodes, connections)); emitHistoryCheckpoint(); }, toggleConnectionTool: () => { setConnectionToolActive((p) => !p); setPendingConnClickSource(null); setConnectingFrom(null); setDragPos(null); }, }), [ connections, emitHistoryCheckpoint, nodes, onConnectionsChange, onNodesChange, selectedConnectionId, selectedNodeIds, selectedStickyId, ] ); 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()); setSelectedStickyId(null); }, []); const handleDeleteConnection = useCallback(() => { if (!selectedConnectionId) return; onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); setSelectedConnectionId(null); emitHistoryCheckpoint(); }, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]); 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 + HANDLE_OFFSET, side: 'bottom' }; const step = w / (ioCount + 1); return { x: node.x + step * (ioIndex + 1), y: node.y + h + HANDLE_OFFSET, side: 'bottom' }; } else { if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; const step = w / (ioCount + 1); return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' }; } }, [] ); const getUsedTargetHandles = useMemo(() => { const used = new Set(); connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`)); return used; }, [connections]); /** Mehrere Kanten auf denselben Eingang: leicht versetzte Ziel-X für sichtbare, getrennte Enden. */ const inboundStacksByTarget = useMemo(() => { const m = new Map(); for (const c of connections) { const key = `${c.targetId}-${c.targetHandle}`; const list = m.get(key); if (list) list.push(c); else m.set(key, [c]); } for (const list of m.values()) { list.sort((a, b) => a.id.localeCompare(b.id)); } return m; }, [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/group-file-ids', 'application/file-id', 'application/file-ids', 'application/group-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)); emitHistoryCheckpoint(); } catch (_) {} }, [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] ); const handleHandleMouseDown = useCallback( (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => { e.stopPropagation(); if (!isOutput) return; if (connectionToolActive) 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, connectionToolActive] ); 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) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex) ) { setSelectedConnectionId(null); return; } onConnectionsChange( connections.map((c) => c.id === selectedConnectionId ? { ...c, targetId: targetNodeId, targetHandle: targetHandleIndex } : c ) ); setSelectedConnectionId(null); emitHistoryCheckpoint(); } return; } const effectiveSource = connectionToolActive && pendingConnClickSource ? pendingConnClickSource : connectingFrom ? { nodeId: connectingFrom.nodeId, handleIndex: connectingFrom.handleIndex } : null; const allowLoopSelfFeedback = !!effectiveSource && targetNode.type === 'flow.loop' && targetHandleIndex === 0 && effectiveSource.handleIndex >= targetNode.inputs; if ( !effectiveSource || (effectiveSource.nodeId === targetNodeId && !allowLoopSelfFeedback) ) { setConnectingFrom(null); setDragPos(null); return; } const key = `${targetNodeId}-${targetHandleIndex}`; if ( getUsedTargetHandles.has(key) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex) ) { setConnectingFrom(null); setDragPos(null); setPendingConnClickSource(null); return; } const newConn: CanvasConnection = { id: `c_${Date.now()}`, sourceId: effectiveSource.nodeId, sourceHandle: effectiveSource.handleIndex, targetId: targetNodeId, targetHandle: targetHandleIndex, }; const srcNode = nodes.find((n) => n.id === effectiveSource.nodeId); const tgtNode = nodes.find((n) => n.id === targetNodeId); if (srcNode && tgtNode) { const sourceOutputIdx = effectiveSource.handleIndex >= srcNode.inputs ? effectiveSource.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); setPendingConnClickSource(null); emitHistoryCheckpoint(); }, [ connectingFrom, connectionToolActive, pendingConnClickSource, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId, nodeTypes, emitHistoryCheckpoint, ] ); React.useEffect(() => { if (!connectingFrom || !dragPos) return; const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY }); const onUp = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest(`.${styles.handleOutput}`)) { return; } if (target.closest(`.${styles.handleInput}`)) { return; } setConnectingFrom(null); setDragPos(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [connectingFrom, dragPos]); const handleNodeMouseDown = useCallback( (e: React.MouseEvent, nodeId: string) => { const node = nodes.find((n) => n.id === nodeId); if (!node) return; const idsToMove = selectedNodeIds.has(nodeId) ? selectedNodeIds : new Set([nodeId]); if (!selectedNodeIds.has(nodeId)) { setSelectedNodeIds(idsToMove); } const nodesInitial: Record = {}; nodes.forEach((n) => { if (idsToMove.has(n.id)) nodesInitial[n.id] = { x: n.x, y: n.y }; }); setDraggingNodeId(nodeId); setDragOffset({ startClientX: e.clientX, startClientY: e.clientY, nodesInitial, }); }, [nodes, selectedNodeIds] ); React.useEffect(() => { if (!draggingNodeId) return; const onMove = (e: MouseEvent) => { const dx = (e.clientX - dragOffset.startClientX) / zoom; const dy = (e.clientY - dragOffset.startClientY) / zoom; onNodesChange( nodes.map((n) => { const init = dragOffset.nodesInitial[n.id]; if (!init) return n; return { ...n, x: init.x + dx, y: init.y + dy }; }) ); }; const onUp = () => { setDraggingNodeId(null); emitHistoryCheckpoint(); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom, emitHistoryCheckpoint]); React.useEffect(() => { if (!stickyDragState) return; const drag = stickyDragState; const onMove = (e: MouseEvent) => { const dx = (e.clientX - drag.startClientX) / zoom; const dy = (e.clientY - drag.startClientY) / zoom; const change = onStickyNotesChangeRef.current; const notes = stickyNotesRef.current; if (!change) return; change( notes.map((s) => s.id === drag.id ? { ...s, x: drag.noteInitial.x + dx, y: drag.noteInitial.y + dy } : s ) ); }; const onUp = () => setStickyDragState(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [stickyDragState, zoom]); React.useEffect(() => { if (!stickyResizeState) return; const r = stickyResizeState; const onMove = (e: MouseEvent) => { const dx = (e.clientX - r.startClientX) / zoom; const dy = (e.clientY - r.startClientY) / zoom; const change = onStickyNotesChangeRef.current; const notes = stickyNotesRef.current; if (!change) return; const nextW = Math.max(STICKY_NOTE_MIN_WIDTH, r.startWidth + dx); const nextH = Math.max(STICKY_NOTE_MIN_HEIGHT, r.startHeight + dy); change(notes.map((s) => (s.id === r.id ? { ...s, width: nextW, height: nextH } : s))); }; const onUp = () => setStickyResizeState(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [stickyResizeState, zoom]); const handleStickyResizeMouseDown = useCallback((e: React.MouseEvent, sn: CanvasStickyNote) => { e.stopPropagation(); e.preventDefault(); const h = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT; setStickyResizeState({ id: sn.id, startClientX: e.clientX, startClientY: e.clientY, startWidth: sn.width, startHeight: h, }); }, []); const handleStickyToolbarMouseDown = useCallback( (e: React.MouseEvent, sn: CanvasStickyNote) => { if ((e.target as HTMLElement).closest('button')) return; e.stopPropagation(); e.preventDefault(); setSelectedStickyId(sn.id); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setEditingStickyId(null); setStickyDragState({ id: sn.id, startClientX: e.clientX, startClientY: e.clientY, noteInitial: { x: sn.x, y: sn.y }, }); }, [] ); 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}`); const hitSticky = (e.target as HTMLElement).closest(`.${styles.canvasStickyNote}`); if (hitNode || hitSticky || connectingFrom) return; if (e.shiftKey) { e.preventDefault(); setSelectedStickyId(null); 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(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, z + delta)) ); }, []); React.useEffect(() => { const el = containerRef.current; if (!el) return; el.addEventListener('wheel', handleWheel, { passive: false }); return () => el.removeEventListener('wheel', handleWheel); }, [handleWheel]); React.useEffect(() => { if (!panning) return; const onMove = (e: MouseEvent) => { setPanOffset({ x: panning.startPanX + (e.clientX - panning.startX), y: panning.startPanY + (e.clientY - panning.startY), }); }; const onUp = () => setPanning(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [panning]); const selectionBoxRef = useRef(null); const marqueeJustEndedRef = useRef(false); selectionBoxRef.current = selectionBox; React.useEffect(() => { if (!selectionBox) return; const onMove = (e: MouseEvent) => { const pt = clientToCanvas(e.clientX, e.clientY); setSelectionBox((prev) => prev ? { ...prev, endX: pt.x, endY: pt.y } : null ); }; const onUp = () => { const box = selectionBoxRef.current; setSelectionBox(null); marqueeJustEndedRef.current = true; if (!box) return; const minX = Math.min(box.startX, box.endX); const maxX = Math.max(box.startX, box.endX); const minY = Math.min(box.startY, box.endY); const maxY = Math.max(box.startY, box.endY); const ids = new Set(); nodes.forEach((n) => { const nx = n.x; const ny = n.y; const nRight = nx + NODE_WIDTH; const nBottom = ny + NODE_HEIGHT; const overlaps = nx < maxX && nRight > minX && ny < maxY && nBottom > minY; if (overlaps) ids.add(n.id); }); setSelectedNodeIds(ids); setSelectedStickyId(null); }; 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; let maxY = 0; nodes.forEach((n) => { maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); maxY = Math.max(maxY, n.y + NODE_HEIGHT + 320); }); 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); emitHistoryCheckpoint(); }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange, emitHistoryCheckpoint]); const handleDeleteSelectedStickyNote = useCallback(() => { const change = onStickyNotesChangeRef.current; if (!selectedStickyId || !change) return; change(stickyNotesRef.current.filter((s) => s.id !== selectedStickyId)); setSelectedStickyId(null); setEditingStickyId(null); emitHistoryCheckpoint(); }, [selectedStickyId, emitHistoryCheckpoint]); 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); setPendingConnClickSource(null); setEditingStickyId(null); setStickyDragState(null); setStickyResizeState(null); setSelectedStickyId(null); } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedConnectionId) { e.preventDefault(); handleDeleteConnection(); } else if (selectedNodeIds.size > 0) { e.preventDefault(); handleDeleteNode(); } else if (selectedStickyId && onStickyNotesChangeRef.current) { e.preventDefault(); handleDeleteSelectedStickyNote(); } } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [ handleDeleteNode, handleDeleteConnection, handleDeleteSelectedStickyNote, selectedNodeIds.size, selectedConnectionId, selectedStickyId, ]); const handleNodeUpdate = useCallback( (nodeId: string, updates: Partial>) => { onNodesChange( nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n)) ); }, [nodes, onNodesChange] ); const patchStickyNote = useCallback( ( id: string, patch: Partial> ) => { onStickyNotesChange?.( stickyNotes.map((s) => (s.id === id ? { ...s, ...patch } : s)) ); }, [stickyNotes, onStickyNotesChange] ); useLayoutEffect(() => { if (!stickyFocusSelectAll || !editingStickyId) return; const ta = stickyTextareaRef.current; if (!ta) return; ta.focus(); ta.select(); setStickyFocusSelectAll(false); }, [editingStickyId, stickyFocusSelectAll, stickyNotes]); return (
e.preventDefault()} onDrop={handleDrop} onMouseDown={handleCanvasMouseDown} tabIndex={0} onClick={() => { if (marqueeJustEndedRef.current) { marqueeJustEndedRef.current = false; return; } setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setConnectingFrom(null); setDragPos(null); setPendingConnClickSource(null); setEditingStickyId(null); setSelectedStickyId(null); }} > {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
{selectedNodeIds.size} {t('Knoten ausgewählt')} {' · '} Entf {t('zum Löschen')} {' · '} {t('Ziehen zum Verschieben')} {' · '} Shift {t('+Klick zum Hinzufügen oder Entfernen')}
)} {connectingFrom && !selectedConnectionId && (
{t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')} {' · '} Esc {t('zum Abbrechen')}
)} {selectedConnectionId && (
{t('Verbindungspfeil ausgewählt')} {' · '} Entf {t('zum Löschen')} {' · '} {t('Anderen Eingang anklicken zum Umleiten')}
)} {connectionToolActive && pendingConnClickSource && !selectedConnectionId && (
{t('Klicken Sie auf einen Eingang, um die Verbindung zu erstellen')} {' · '} Esc {t('zum Abbrechen')}
)} {connectionToolActive && !pendingConnClickSource && !connectingFrom && !selectedConnectionId && selectedNodeIds.size <= 1 && (
{t('Klicken Sie auf einen Ausgang, dann auf einen Eingang')} {' · '} Esc {t('zum Abbrechen')}
)}
{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 tgtBase = getHandlePosition(tgtNode, c.targetHandle); const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c]; const si = stack.findIndex((x) => x.id === c.id); const spread = 12; const tgt = stack.length > 1 ? { ...tgtBase, x: tgtBase.x + (si - (stack.length - 1) / 2) * spread } : tgtBase; const feedback = isLoopFeedbackEdge(c, srcNode, tgtNode); const pathD = connectionPathD(src, tgt, srcNode, tgtNode, feedback, nodes); const isSelected = selectedConnectionId === c.id; const isWarning = connectionWarnings[c.id]; const strokeColor = isSelected ? 'var(--primary-color, #007bff)' : isWarning ? '#FF9800' : 'var(--text-secondary, #666)'; return ( handleConnectionClick(e, c.id)} style={{ cursor: 'pointer' }} role="button" tabIndex={-1} aria-label={t('Verbindung auswählen, Entf zum Löschen')} > {isWarning && !isSelected && ( {t('Typeninkompatibilität: Ausgabetyp')} )} ); })} {connectingFrom && dragPos && (() => { const end = clientToCanvas(dragPos.x, dragPos.y); return ( ); })()} {nodes.map((node) => { const nt = nodeTypeMap[node.type]; const category = nt?.category ?? 'io'; const color = node.color ?? nt?.meta?.color ?? '#00BCD4'; const handles: Array<{ index: number; isOutput: boolean }> = []; for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false }); for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true }); const wireSourceNode = !selectedConnectionId && connectingFrom ? nodes.find((n) => n.id === connectingFrom.nodeId) : !selectedConnectionId && connectionToolActive && pendingConnClickSource ? nodes.find((n) => n.id === pendingConnClickSource.nodeId) : null; const wireSourceHandleIdx = connectingFrom?.handleIndex ?? pendingConnClickSource?.handleIndex ?? -1; const isSelected = selectedNodeIds.has(node.id); const isEditingTitle = editingNodeId === node.id && editingField === 'title'; const displayTitle = node.title ?? node.label ?? getLabel(node); const hlStatus = highlightedNodeIds?.[node.id]; const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null; return (
e.stopPropagation()} onMouseDown={(e) => { e.stopPropagation(); setSelectedConnectionId(null); setSelectedStickyId(null); if (e.shiftKey) { setSelectedNodeIds((prev) => { const next = new Set(prev); if (next.has(node.id)) next.delete(node.id); else next.add(node.id); return next; }); return; } if (!selectedNodeIds.has(node.id)) { setSelectedNodeIds(new Set([node.id])); } handleNodeMouseDown(e, node.id); }} > {nt?.meta?.usesAi === true && ( )} {nodeErrors?.[node.id]?.length ? (
e.paramLabel).join(', ') } style={{ position: 'absolute', top: -8, right: -8, minWidth: 20, height: 20, borderRadius: 10, padding: '0 6px', background: 'var(--danger-color, #dc3545)', color: '#fff', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.25)', zIndex: 5, pointerEvents: 'auto', }} > {nodeErrors[node.id].length}
) : null} {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`) && !allowsMultipleInboundOnInputPort(node, 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 && wireSourceNode && wireSourceHandleIdx >= 0) { const sourceOutputIdx = wireSourceHandleIdx >= wireSourceNode.inputs ? wireSourceHandleIdx - wireSourceNode.inputs : 0; wireTargetOk = _checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok'; } const canConnect = isOutput || (!used && !!wireSourceNode && wireSourceHandleIdx >= 0 && (!selectedConnectionId ? wireTargetOk : true)) || (!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection)); const nt = nodeTypeMap[node.type]; const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined; return (
{outputLabel && pos.side === 'bottom' && isOutput ? ( <>
handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} onClick={(e) => { if (!connectionToolActive || !isOutput) return; e.stopPropagation(); setPendingConnClickSource({ nodeId: node.id, handleIndex: index }); setConnectingFrom(null); setDragPos(null); }} title={outputLabel} /> {outputLabel} ) : ( <>
handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} onClick={(e) => { if (!connectionToolActive || !isOutput) return; e.stopPropagation(); setPendingConnClickSource({ nodeId: node.id, handleIndex: index }); setConnectingFrom(null); setDragPos(null); }} title={ outputLabel ?? (selectedConnectionId && !isOutput ? used ? t('Aktuelles Ziel klicken, um abzuwählen') : t('Klicken zum Umleiten') : undefined) } /> {outputLabel && pos.side === 'top' && ( {outputLabel} )} )}
); })}
{getCategoryIcon(category)}
{isEditingTitle ? ( e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') { handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value }); setEditingNodeId(null); setEditingField(null); } if (e.key === 'Escape') { setEditingNodeId(null); setEditingField(null); } }} onBlur={(e) => { handleNodeUpdate(node.id, { title: e.target.value }); setEditingNodeId(null); setEditingField(null); }} onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })} /> ) : ( { e.stopPropagation(); setEditingNodeId(node.id); setEditingField('title'); }} > {displayTitle} )} {node.comment && ( {node.comment} )}
{node.comment && (
{node.comment}
)}
); })} {stickyNotes.map((sn) => { const pal = getStickyNotePaletteEntry(sn.colorId); const activeColorId = sn.colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID; const contentH = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT; return (
{ e.stopPropagation(); setSelectedStickyId(sn.id); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); }} onClick={(e) => e.stopPropagation()} >
handleStickyToolbarMouseDown(e, sn)} > ⋮⋮ {selectedStickyId === sn.id ? (
{STICKY_NOTE_PALETTE.map((p) => (
) : null}
{editingStickyId === sn.id ? (