+
+ {onToggleWorkspacePanel && (
+
+ )}
+
+
+
+ {onNewFromTemplate && (
+ setNewMenuOpen((p) => !p)}
+ title={t('Aus Vorlage…')}
+ aria-label={t('Neu aus Vorlage')}
+ aria-haspopup="menu"
+ aria-expanded={newMenuOpen}
+ />
+ )}
+
+ {newMenuOpen && onNewFromTemplate && (
+
+ {
+ onNewFromTemplate();
+ setNewMenuOpen(false);
+ }}
+ role="menuitem"
+ >
+ {t('Aus Vorlage…')}
+
+
+ )}
+
= ({ workflows,
))}
-
- {currentWorkflowId && currentWorkflow ? (
- editingName ? (
- setNameValue(e.target.value)}
- onBlur={_commitNameEdit}
- onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
- />
- ) : (
-
- {currentWorkflow.label}
-
- )
- ) : (
-
- {t('Neuer Workflow')}
-
- )}
-
- {onWorkflowSettings && (
-
-
-
- )}
- {targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
-
onTargetInstanceChange(e.target.value)}
- aria-label={t('Ziel-Instanz')}
- title={t('Ziel-Instanz für Daten-Scope')}
- style={{ maxWidth: 200, fontSize: '0.8rem' }}
- >
- {t('Ziel-Instanz wählen…')}
- {targetInstanceOptions.map((opt) => (
- {opt.label}
- ))}
-
- )}
-
-
-
-
-
-
- {t('Neu')}
-
- setNewMenuOpen((p) => !p)}
- title={t('Neu aus Vorlage')}
- aria-haspopup="menu"
- aria-expanded={newMenuOpen}
- >
-
-
-
- {newMenuOpen && (
-
- { onNew(); setNewMenuOpen(false); }}
- role="menuitem"
- >
- {t('Leerer Workflow')}
-
- {onNewFromTemplate && (
- { onNewFromTemplate(); setNewMenuOpen(false); }}
- role="menuitem"
- >
- {t('Aus Vorlage…')}
-
- )}
-
- )}
-
-
-
- {saving ? : t('Speichern')}
-
-
- {onAutoLayout && (
-
-
- {t('Anordnen')}
-
- )}
-
+ onClick={onSave}
+ title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
+ aria-label={t('Speichern')}
+ />
+
{
+ if (executeBlockedReason) {
+ onExecuteBlockedClick?.();
+ return;
+ }
+ onExecute();
+ }}
+ aria-label={_runAriaLabel}
+ aria-disabled={executing || !hasNodes || !!executeBlockedReason}
+ title={_runTitle}
+ />
{currentWorkflowId && onSaveAsTemplate && (
-
setTemplateMenuOpen((p) => !p)}
+ variant={_tb}
+ size={_ts}
+ icon={FaBookmark}
+ loading={templateSaving}
disabled={templateSaving}
+ onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
- {templateSaving ? : <> {t('Als Vorlage')}>}
-
+ {t('Als Vorlage')}
+
{templateMenuOpen && (
{(['user', 'instance', 'mandate'] as const).map((s) => (
@@ -325,7 +349,10 @@ export const CanvasHeader: React.FC = ({ workflows,
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
- onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
+ onClick={() => {
+ onSaveAsTemplate(s);
+ setTemplateMenuOpen(false);
+ }}
role="menuitem"
>
{scopeLabels[s]}
@@ -336,53 +363,6 @@ export const CanvasHeader: React.FC = ({ workflows,
)}
-
{
- if (executeBlockedReason) {
- onExecuteBlockedClick?.();
- return;
- }
- onExecute();
- }}
- disabled={executing || !hasNodes}
- aria-disabled={executing || !hasNodes || !!executeBlockedReason}
- title={executeBlockedReason ?? undefined}
- style={
- executeBlockedReason
- ? {
- background: 'rgba(220,53,69,0.10)',
- borderColor: 'var(--danger-color, #dc3545)',
- color: 'var(--danger-color, #dc3545)',
- cursor: 'help',
- }
- : undefined
- }
- >
- {executing ? (
- <>
-
- {t('Ausführen…')}
- >
- ) : executeBlockedReason ? (
- <>
-
- {t('Pflicht-Felder fehlen')}
- >
- ) : (
- <>
-
- {t('Ausführen')}
- >
- )}
-
- {onToggleChat && (
-
-
- {t('Workspace')}
-
- )}
{_isSysAdmin && onVerboseSchemaChange && (
= ({ workflows,
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
- style={{ margin: 0 }}
+ className={styles.canvasHeaderSysadminInput}
/>
{t('Schema-Details')}
)}
-
+ {canvasEdit && (
+
+
+
+ setZoomInputDraft(e.target.value)}
+ onBlur={_commitZoomDraft}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ _commitZoomDraft();
+ }
+ }}
+ aria-label={t('Zoomstufe (Prozent)')}
+ title={t('Zoomstufe (Prozent)')}
+ />
+
+ %
+
+
+
setZoomMenuOpen((p) => !p)}
+ aria-label={t('Zoom-Voreinstellungen')}
+ aria-haspopup="menu"
+ aria-expanded={zoomMenuOpen}
+ title={t('Zoom-Voreinstellungen')}
+ >
+
+
+ {zoomMenuOpen && (
+
+ {
+ canvasEdit.onFitWindow();
+ setZoomMenuOpen(false);
+ }}
+ >
+ {t('Ansicht an Fenster anpassen')}
+
+ {
+ canvasEdit.onResetView();
+ setZoomMenuOpen(false);
+ }}
+ >
+ {t('Ansicht zurücksetzen')}
+
+ {ZOOM_PRESET_PERCENTS.map((pct) => (
+ {
+ canvasEdit.onZoomPercentCommit(pct);
+ setZoomMenuOpen(false);
+ }}
+ >
+ {pct}%
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
{currentWorkflowId && versions && versions.length > 0 && (
{t('Version:')}
@@ -418,108 +557,94 @@ export const CanvasHeader: React.FC = ({ workflows,
))}
{badge.label}
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
- onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version veröffentlichen')}
- style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
-
{t('Veröffentlichen')}
-
+
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
- onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')}
- style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
-
{t('Veröffentlichung aufheben')}
-
+
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
- onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version archivieren')}
- style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
-
- Archiv
-
+ {t('Archiv')}
+
)}
{onCreateDraft && (
-
- + Entwurf
-
+ {t('+ Entwurf')}
+
)}
- {versionLoading && }
+ {versionLoading && }
)}
{executeResult && (
{executeResult.success ? (
executeResult.warning ? (
- <>⚠ {executeResult.warning}>
+ <>{executeResult.warning}>
) : (
<>{t('Ausführung abgeschlossen')}>
)
- ) : (executeResult as { paused?: boolean }).paused ? (
+ ) : executeResult.paused ? (
<>
- ⏸ Workflow pausiert. Öffne {t('Workflows/Tasks')} in der Sidebar, um den
- Task zu bearbeiten.
+ {t('Workflow pausiert. Öffne ')}
+ {t('Workflows/Tasks')}
+ {t(' in der Sidebar, um den Task zu bearbeiten.')}
>
) : (
- <>✗ {executeResult.error ?? t('Unbekannter Fehler')}>
+ <>{executeResult.error ?? t('Unbekannter Fehler')}>
)}
)}
diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx
index c4f8147..5a8010b 100644
--- a/src/components/FlowEditor/editor/FlowCanvas.tsx
+++ b/src/components/FlowEditor/editor/FlowCanvas.tsx
@@ -3,12 +3,22 @@
* 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 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';
+import { switchOutputLabel } from '../nodes/shared/graphUtils';
export interface CanvasNode {
id: string;
@@ -34,22 +44,352 @@ export interface CanvasConnection {
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;
+/** Must match `.canvasNode { border: … solid }` — handles sit in the padding box. */
+const NODE_BORDER = 2;
+
+export const FLOW_CANVAS_MIN_ZOOM = 0.25;
+export const FLOW_CANVAS_MAX_ZOOM = 4;
+
+function deepCloneCanvasNode(node: CanvasNode): CanvasNode {
+ return {
+ ...node,
+ parameters: node.parameters ? { ...node.parameters } : {},
+ inputPorts: node.inputPorts?.map((p) => ({ ...p })),
+ outputPorts: node.outputPorts?.map((p) => ({ ...p })),
+ };
+}
+
+/** Konfig-/Sidebar-/Header blenden Knoten-Duplizieren per Strg+C aus (normales Kopieren). */
+const FLOW_HOTKEY_SHIELD_SELECTOR = '[data-suppress-flow-node-hotkeys]';
+
+function isDuplicateNodeHotkeyShielded(el: HTMLElement): boolean {
+ return el.closest(FLOW_HOTKEY_SHIELD_SELECTOR) != null;
+}
+
+function isKeyboardTypingTarget(el: HTMLElement): boolean {
+ if (el.isContentEditable) return true;
+ const t = el.tagName;
+ return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT';
+}
+
+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]);
+ }
+}
+
/**
- * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers.
- * Disconnected nodes are appended as extra roots.
+ * Topologische Schichten (Kahn): chronologisch von Quellen zu Senken.
+ * Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup.
*/
-export function computeAutoLayout(
- nodes: CanvasNode[],
- connections: CanvasConnection[],
-): CanvasNode[] {
- if (nodes.length === 0) return nodes;
+function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] {
+ if (nodes.length === 0) return [];
const inDegree = new Map();
const children = new Map();
@@ -58,6 +398,7 @@ export function computeAutoLayout(
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);
}
@@ -87,12 +428,32 @@ export function computeAutoLayout(
const placed = new Set(layerOf.keys());
for (const n of nodes) {
if (!placed.has(n.id)) {
- const layerIdx = layers.length;
layers.push([n.id]);
- layerOf.set(n.id, layerIdx);
+ 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;
@@ -108,6 +469,84 @@ export function computeAutoLayout(
});
}
+/**
+ * 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';
@@ -139,6 +578,130 @@ function _checkConnectionCompatibility(
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;
+
+ 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[],
+ /** Trennt überlagernde Kanten in der Kurvenmitte — Endpunkt bleibt am Handle-Mittelpunkt. */
+ lateralBias = 0,
+): string {
+ if (!feedback) {
+ const dy = tgt.y - src.y;
+ const mx = Math.abs(dy) / 2;
+ const b = lateralBias;
+ return `M ${src.x} ${src.y} C ${src.x + b} ${src.y + mx}, ${tgt.x + b} ${tgt.y - mx}, ${tgt.x} ${tgt.y}`;
+ }
+ return feedbackConnectionPathD(src, tgt, srcNode, tgtNode, allNodes);
+}
+
interface FlowCanvasProps {
nodes: CanvasNode[];
connections: CanvasConnection[];
@@ -158,6 +721,13 @@ interface FlowCanvasProps {
* 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 = {
@@ -167,21 +737,36 @@ const HIGHLIGHT_COLORS: Record = {
skipped: '#6c757d',
};
-export const FlowCanvas: React.FC = ({ nodes,
- connections,
- nodeTypes,
- onNodesChange,
- onConnectionsChange,
- onDropNodeType,
- getLabel,
- getCategoryIcon,
- onSelectionChange,
- highlightedNodeIds,
- nodeErrors,
- onExternalDrop,
-}) => {
+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);
@@ -209,6 +794,13 @@ export const FlowCanvas: React.FC = ({ nodes,
});
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;
@@ -216,6 +808,30 @@ export const FlowCanvas: React.FC = ({ nodes,
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) => {
@@ -224,6 +840,185 @@ export const FlowCanvas: React.FC = ({ nodes,
return m;
}, [nodeTypes]);
+ const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
+ onHistoryCheckpointRef.current = onHistoryCheckpoint;
+
+ const emitHistoryCheckpoint = useCallback(() => {
+ onHistoryCheckpointRef.current?.();
+ }, []);
+
+ const nodesRef = useRef(nodes);
+ nodesRef.current = nodes;
+
+ 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 = {
+ ...deepCloneCanvasNode(node),
+ id: newId,
+ x: node.x + 40,
+ y: node.y + 40,
+ };
+ 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;
@@ -235,13 +1030,15 @@ export const FlowCanvas: React.FC = ({ nodes,
e.stopPropagation();
setSelectedConnectionId(connId);
setSelectedNodeIds(new Set());
+ setSelectedStickyId(null);
}, []);
const handleDeleteConnection = useCallback(() => {
if (!selectedConnectionId) return;
onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
setSelectedConnectionId(null);
- }, [selectedConnectionId, connections, onConnectionsChange]);
+ emitHistoryCheckpoint();
+ }, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]);
const getHandlePosition = useCallback(
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
@@ -249,19 +1046,20 @@ export const FlowCanvas: React.FC = ({ nodes,
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;
+ const innerLeft = node.x + NODE_BORDER;
+ const innerTop = node.y + NODE_BORDER;
+ const innerBottom = node.y + NODE_HEIGHT - NODE_BORDER;
+ const innerWidth = NODE_WIDTH - 2 * NODE_BORDER;
+ const centerX = innerLeft + innerWidth / 2;
if (isOutput) {
- if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' };
- const step = w / (ioCount + 1);
- return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' };
- } else {
- if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
- const step = w / (ioCount + 1);
- return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
+ if (ioCount === 1) return { x: centerX, y: innerBottom, side: 'bottom' };
+ const step = innerWidth / (ioCount + 1);
+ return { x: innerLeft + step * (ioIndex + 1), y: innerBottom, side: 'bottom' };
}
+ if (ioCount === 1) return { x: centerX, y: innerTop, side: 'top' };
+ const step = innerWidth / (ioCount + 1);
+ return { x: innerLeft + step * (ioIndex + 1), y: innerTop, side: 'top' };
},
[]
);
@@ -272,6 +1070,21 @@ export const FlowCanvas: React.FC = ({ nodes,
return used;
}, [connections]);
+ /** Mehrere Kanten auf denselben Eingang: Kurven seitlich versetzen (Endpunkt = Handle-Mitte). */
+ 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();
@@ -308,22 +1121,24 @@ export const FlowCanvas: React.FC = ({ nodes,
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]
+ [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]
+ [nodes, getHandlePosition, connectionToolActive]
);
const handleHandleMouseUp = useCallback(
@@ -341,7 +1156,10 @@ export const FlowCanvas: React.FC = ({ nodes,
setSelectedConnectionId(null);
return;
}
- if (getUsedTargetHandles.has(key)) {
+ if (
+ getUsedTargetHandles.has(key) &&
+ !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
+ ) {
setSelectedConnectionId(null);
return;
}
@@ -353,35 +1171,63 @@ export const FlowCanvas: React.FC = ({ nodes,
)
);
setSelectedConnectionId(null);
+ emitHistoryCheckpoint();
}
return;
}
- if (!connectingFrom || connectingFrom.nodeId === targetNodeId) {
+ 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)) {
+ if (
+ getUsedTargetHandles.has(key) &&
+ !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
+ ) {
setConnectingFrom(null);
setDragPos(null);
+ setPendingConnClickSource(null);
return;
}
const newConn: CanvasConnection = {
id: `c_${Date.now()}`,
- sourceId: connectingFrom.nodeId,
- sourceHandle: connectingFrom.handleIndex,
+ sourceId: effectiveSource.nodeId,
+ sourceHandle: effectiveSource.handleIndex,
targetId: targetNodeId,
targetHandle: targetHandleIndex,
};
- const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId);
+ const srcNode = nodes.find((n) => n.id === effectiveSource.nodeId);
const tgtNode = nodes.find((n) => n.id === targetNodeId);
if (srcNode && tgtNode) {
- const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs
- ? connectingFrom.handleIndex - srcNode.inputs : 0;
- const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes);
+ 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 }));
}
@@ -390,12 +1236,25 @@ export const FlowCanvas: React.FC = ({ nodes,
onConnectionsChange([...connections, newConn]);
setConnectingFrom(null);
setDragPos(null);
+ setPendingConnClickSource(null);
+ emitHistoryCheckpoint();
},
- [connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId]
+ [
+ connectingFrom,
+ connectionToolActive,
+ pendingConnClickSource,
+ connections,
+ nodes,
+ getUsedTargetHandles,
+ onConnectionsChange,
+ selectedConnectionId,
+ nodeTypes,
+ emitHistoryCheckpoint,
+ ]
);
React.useEffect(() => {
- if (!connectingFrom || !dragPos) return;
+ if (!connectingFrom) return;
const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
const onUp = (e: MouseEvent) => {
const target = e.target as HTMLElement;
@@ -414,7 +1273,7 @@ export const FlowCanvas: React.FC = ({ nodes,
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
- }, [connectingFrom, dragPos]);
+ }, [connectingFrom]);
const handleNodeMouseDown = useCallback(
(e: React.MouseEvent, nodeId: string) => {
@@ -439,6 +1298,10 @@ export const FlowCanvas: React.FC = ({ nodes,
startClientY: e.clientY,
nodesInitial,
});
+
+ queueMicrotask(() => {
+ containerRef.current?.focus({ preventScroll: true });
+ });
},
[nodes, selectedNodeIds]
);
@@ -456,42 +1319,117 @@ export const FlowCanvas: React.FC = ({ nodes,
})
);
};
- const onUp = () => setDraggingNodeId(null);
+ 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]);
+ }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom, emitHistoryCheckpoint]);
- 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 });
+ 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
+ )
+ );
};
- update();
- window.addEventListener('resize', update);
- return () => window.removeEventListener('resize', update);
+ 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 clientToCanvas = useCallback(
- (clientX: number, clientY: number) => ({
- x: (clientX - containerBounds.left - panOffset.x) / zoom,
- y: (clientY - containerBounds.top - panOffset.y) / zoom,
- }),
- [containerBounds, panOffset, zoom]
+ 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 },
+ });
+ },
+ []
);
+ /** Immer aktuelle Viewport-Lage (Scroll, Resize, verschobene Panels) — sonst klebt die Verbindungs-Hilfslinie falsch. */
+ const clientToCanvas = useCallback((clientX: number, clientY: number) => {
+ const el = containerRef.current;
+ if (!el) return { x: 0, y: 0 };
+ const r = el.getBoundingClientRect();
+ return {
+ x: (clientX - r.left - panOffset.x) / zoom,
+ y: (clientY - r.top - panOffset.y) / zoom,
+ };
+ }, [panOffset, zoom]);
+
const handleCanvasMouseDown = useCallback(
(e: React.MouseEvent) => {
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
- if (hitNode || connectingFrom) return;
+ 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());
@@ -511,7 +1449,9 @@ export const FlowCanvas: React.FC = ({ nodes,
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)));
+ setZoom((z) =>
+ Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, z + delta))
+ );
}, []);
React.useEffect(() => {
@@ -570,6 +1510,7 @@ export const FlowCanvas: React.FC = ({ nodes,
if (overlaps) ids.add(n.id);
});
setSelectedNodeIds(ids);
+ setSelectedStickyId(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@@ -582,10 +1523,11 @@ export const FlowCanvas: React.FC = ({ nodes,
const CANVAS_SIZE = 8000;
const svgBounds = useMemo(() => {
if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE };
- let maxX = 0, maxY = 0;
+ 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 + 200);
+ maxY = Math.max(maxY, n.y + NODE_HEIGHT + 320);
});
return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
}, [nodes]);
@@ -602,16 +1544,58 @@ export const FlowCanvas: React.FC = ({ nodes,
setSelectedNodeIds(new Set());
setEditingNodeId(null);
setEditingField(null);
- }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]);
+ 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;
+ const target = e.target as HTMLElement | null;
+ if (!target) return;
+
+ const mod = e.ctrlKey || e.metaKey;
+ if (mod && e.code === 'KeyC') {
+ if (selectedConnectionId || selectedStickyId || !selectedNodeId) return;
+ if (isDuplicateNodeHotkeyShielded(target)) return;
+ const node = nodesRef.current.find((n) => n.id === selectedNodeId);
+ if (!node) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const newId = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+ const clone: CanvasNode = {
+ ...deepCloneCanvasNode(node),
+ id: newId,
+ x: node.x + 40,
+ y: node.y + 40,
+ };
+ onNodesChange([...nodesRef.current, clone]);
+ setSelectedConnectionId(null);
+ setSelectedNodeIds(new Set([newId]));
+ setSelectedStickyId(null);
+ setEditingNodeId(null);
+ setEditingField(null);
+ emitHistoryCheckpoint();
+ return;
+ }
+
+ if (isKeyboardTypingTarget(target)) 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) {
@@ -620,12 +1604,25 @@ export const FlowCanvas: React.FC = ({ nodes,
} 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, selectedNodeIds.size, selectedConnectionId]);
+ window.addEventListener('keydown', onKeyDown, true);
+ return () => window.removeEventListener('keydown', onKeyDown, true);
+ }, [
+ handleDeleteNode,
+ handleDeleteConnection,
+ handleDeleteSelectedStickyNote,
+ emitHistoryCheckpoint,
+ onNodesChange,
+ selectedNodeIds.size,
+ selectedNodeId,
+ selectedConnectionId,
+ selectedStickyId,
+ ]);
const handleNodeUpdate = useCallback(
(nodeId: string, updates: Partial>) => {
@@ -636,10 +1633,31 @@ export const FlowCanvas: React.FC = ({ nodes,
[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 (
= ({ nodes,
setSelectedConnectionId(null);
setConnectingFrom(null);
setDragPos(null);
+ setPendingConnClickSource(null);
+ setEditingStickyId(null);
+ setSelectedStickyId(null);
}}
>
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
@@ -687,6 +1708,24 @@ export const FlowCanvas: React.FC = ({ nodes,
{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')}
+
+ )}
= ({ nodes,
className={styles.connectionsLayer}
width={svgBounds.width}
height={svgBounds.height}
- style={{ position: 'absolute', left: 0, top: 0 }}
+ style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}
>
@@ -715,9 +1755,10 @@ export const FlowCanvas: React.FC = ({ nodes,
@@ -725,9 +1766,10 @@ export const FlowCanvas: React.FC = ({ nodes,
@@ -739,9 +1781,13 @@ export const FlowCanvas: React.FC = ({ nodes,
const tgtNode = nodes.find((n) => n.id === c.targetId);
if (!srcNode || !tgtNode) return null;
const src = getHandlePosition(srcNode, c.sourceHandle);
- const tgt = getHandlePosition(tgtNode, c.targetHandle);
- const dy = tgt.y - src.y;
- const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`;
+ const tgtBase = getHandlePosition(tgtNode, c.targetHandle);
+ const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c];
+ const si = stack.findIndex((x) => x.id === c.id);
+ const lateralBias =
+ stack.length > 1 ? (si - (stack.length - 1) / 2) * 14 : 0;
+ const feedback = isLoopFeedbackEdge(c, srcNode, tgtNode);
+ const pathD = connectionPathD(src, tgtBase, srcNode, tgtNode, feedback, nodes, lateralBias);
const isSelected = selectedConnectionId === c.id;
const isWarning = connectionWarnings[c.id];
const strokeColor = isSelected
@@ -770,6 +1816,8 @@ export const FlowCanvas: React.FC = ({ nodes,
fill="none"
stroke={strokeColor}
strokeWidth={isSelected ? 3 : 2}
+ strokeLinecap="round"
+ strokeLinejoin="round"
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
pointerEvents="none"
@@ -803,7 +1851,14 @@ export const FlowCanvas: React.FC = ({ nodes,
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
const wireSourceNode =
- connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null;
+ !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';
@@ -828,6 +1883,7 @@ export const FlowCanvas: React.FC = ({ nodes,
onMouseDown={(e) => {
e.stopPropagation();
setSelectedConnectionId(null);
+ setSelectedStickyId(null);
if (e.shiftKey) {
setSelectedNodeIds((prev) => {
const next = new Set(prev);
@@ -881,25 +1937,37 @@ export const FlowCanvas: React.FC = ({ nodes,
) : null}
{handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index);
- const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${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 && connectingFrom && !selectedConnectionId && wireSourceNode) {
+ if (!isOutput && wireSourceNode && wireSourceHandleIdx >= 0) {
const sourceOutputIdx =
- connectingFrom.handleIndex >= wireSourceNode.inputs
- ? connectingFrom.handleIndex - wireSourceNode.inputs
+ wireSourceHandleIdx >= wireSourceNode.inputs
+ ? wireSourceHandleIdx - wireSourceNode.inputs
: 0;
wireTargetOk =
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
}
const canConnect =
isOutput ||
- (!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
+ (!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;
+ const outputIndex = index - node.inputs;
+ const outputLabel =
+ isOutput && node.type === 'flow.switch'
+ ? switchOutputLabel(node, outputIndex, t)
+ : isOutput && nt?.outputLabels
+ ? nt.outputLabels[outputIndex]
+ : undefined;
return (
= ({ nodes,
style={{
top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
- left: pos.x - node.x - HANDLE_OFFSET,
+ left: pos.x - node.x - NODE_BORDER - HANDLE_OFFSET,
}}
>
- {outputLabel && pos.side === 'bottom' && (
-
{outputLabel}
- )}
-
handleHandleMouseDown(e, node.id, index, isOutput)}
- onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
- title={
- outputLabel ??
- (selectedConnectionId && !isOutput
- ? used
- ? t('Aktuelles Ziel klicken, um abzuwählen')
- : t('Klicken zum Umleiten')
- : undefined)
- }
- />
- {outputLabel && pos.side === 'top' && (
-
{outputLabel}
+ {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}
+ )}
+ >
)}
);
@@ -945,6 +2039,7 @@ export const FlowCanvas: React.FC
= ({ nodes,
e.stopPropagation()}
@@ -989,6 +2084,132 @@ export const FlowCanvas: React.FC = ({ nodes,
);
})}
+ {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) => (
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ patchStickyNote(sn.id, { colorId: p.id });
+ }}
+ />
+ ))}
+
+ ) : null}
+
+ {editingStickyId === sn.id ? (
+
+ );
+ })}
{selectionBox && (
= ({ nodes,
);
-};
+});
diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx
index ce6f3f1..c60600b 100644
--- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx
+++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx
@@ -15,6 +15,82 @@ import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { AccordionList } from '../../UiComponents/AccordionList';
+import type { AccordionListItem } from '../../UiComponents/AccordionList';
+
+const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
+const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
+const CONTEXT_EXTRACT_CHUNK_SET = new Set
(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
+
+/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
+export function workflowParamUiValue(stored: Record, param: NodeTypeParameter): unknown {
+ const raw = stored[param.name];
+ if (param.required) {
+ return raw !== undefined && raw !== null ? raw : param.default;
+ }
+ return raw;
+}
+
+function effectiveSchemaParamString(name: string, currentParams: Record, nt: NodeType): string {
+ const raw = currentParams[name];
+ const s = raw !== undefined && raw !== null ? String(raw) : '';
+ if (s !== '') return s;
+ const meta = nt.parameters?.find((p) => p.name === name);
+ const d = meta?.default;
+ return d !== undefined && d !== null ? String(d) : '';
+}
+
+function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
+ return (
+
+ {param.required ? (
+
+ *
+
+ ) : null}
+ {param.name}
+
+ );
+}
+
+function verboseSchemaTypeBadge(
+ verboseSchema: boolean,
+ param: NodeTypeParameter,
+ t: (key: string) => string,
+): React.ReactElement | null {
+ if (!verboseSchema || !param.type) return null;
+ return (
+
+
+ {param.type}
+
+
+ );
+}
interface NodeConfigPanelProps {
node: CanvasNode | null;
@@ -30,6 +106,35 @@ interface NodeConfigPanelProps {
verboseSchema?: boolean;
}
+/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
+ * (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
+ * parameter unless the referenced parameter's effective value matches.
+ */
+export function parameterVisibleForFrontendOptions(
+ param: NodeTypeParameter,
+ params: Record,
+ nodeType: NodeType,
+): boolean {
+ const fo = param.frontendOptions;
+ if (!fo || typeof fo !== 'object') return true;
+ const dependsOnRaw = fo.dependsOn as unknown;
+ const showWhenRaw = fo.showWhen as unknown;
+ if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
+ return true;
+ }
+ const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
+ const rawSibling = params[dependsOnRaw];
+ const siblingValue =
+ rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
+ const fallback =
+ depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
+ const effective = siblingValue !== '' ? siblingValue : fallback;
+ const allowed: string[] = Array.isArray(showWhenRaw)
+ ? showWhenRaw.map((x) => String(x))
+ : [String(showWhenRaw)];
+ return allowed.includes(effective);
+}
+
export const NodeConfigPanel: React.FC = ({ node,
nodeType,
language,
@@ -62,7 +167,32 @@ export const NodeConfigPanel: React.FC = ({ node,
const updateParam = useCallback(
(key: string, value: unknown) => {
setParams((prev) => {
- const next = { ...prev, [key]: value };
+ const next = { ...prev };
+ if (value === undefined) {
+ delete next[key];
+ } else {
+ next[key] = value;
+ }
+ const id = nodeIdRef.current;
+ if (id) {
+ if (notifyParentTimeoutRef.current != null) {
+ clearTimeout(notifyParentTimeoutRef.current);
+ }
+ notifyParentTimeoutRef.current = setTimeout(() => {
+ notifyParentTimeoutRef.current = null;
+ onParametersChange(id, next);
+ }, 0);
+ }
+ return next;
+ });
+ },
+ [onParametersChange]
+ );
+
+ const patchParams = useCallback(
+ (patch: Record) => {
+ setParams((prev) => {
+ const next = { ...prev, ...patch };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
@@ -115,6 +245,139 @@ export const NodeConfigPanel: React.FC = ({ node,
.join('\n');
}, [requiredErrors, nodeType, language]);
+ const extractContentAccordionItems = useMemo((): AccordionListItem[] | null => {
+ if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
+
+ const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
+ const out: AccordionListItem[] = [];
+
+ for (const param of sortedParameters) {
+ if (param.frontendType === 'hidden') continue;
+ if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
+ if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
+
+ const usePicker = _shouldUseRequiredPicker(param);
+ if (usePicker) {
+ out.push({
+ id: param.name,
+ title: accordionExtractParamTitle(param, t),
+ children: (
+
+ {verboseSchemaTypeBadge(verboseSchema, param, t)}
+ updateParam(param.name, val)}
+ />
+
+ ),
+ });
+ continue;
+ }
+
+ const frontendType = param.frontendType || 'text';
+ const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
+
+ if (param.name === 'outputMode') {
+ const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
+ out.push({
+ id: param.name,
+ title: accordionExtractParamTitle(param, t),
+ children: (
+
+ {verboseSchemaTypeBadge(verboseSchema, param, t)}
+
updateParam(param.name, val)}
+ allParams={params}
+ instanceId={instanceId}
+ request={request}
+ nodeType={node.type}
+ onPatchParams={patchParams}
+ hideAccordionTitle
+ />
+ {chunksNested ? (
+
+
+ key={`extract-chunks-${node.id}`}
+ defaultOpenId={null}
+ items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem => {
+ const cp = byName.get(chunkName);
+ if (!cp) {
+ return { id: chunkName, title: chunkName, children: <>> };
+ }
+ const ft = cp.frontendType || 'text';
+ const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
+ return {
+ id: chunkName,
+ title: accordionExtractParamTitle(cp, t),
+ children: (
+
+ {verboseSchemaTypeBadge(verboseSchema, cp, t)}
+ updateParam(cp.name, val)}
+ allParams={params}
+ instanceId={instanceId}
+ request={request}
+ nodeType={node.type}
+ onPatchParams={patchParams}
+ hideAccordionTitle
+ />
+
+ ),
+ };
+ })}
+ />
+
+ ) : null}
+
+ ),
+ });
+ continue;
+ }
+
+ out.push({
+ id: param.name,
+ title: accordionExtractParamTitle(param, t),
+ children: (
+
+ {verboseSchemaTypeBadge(verboseSchema, param, t)}
+ updateParam(param.name, val)}
+ allParams={params}
+ instanceId={instanceId}
+ request={request}
+ nodeType={node.type}
+ onPatchParams={patchParams}
+ hideAccordionTitle
+ />
+
+ ),
+ });
+ }
+
+ return out;
+ }, [
+ sortedParameters,
+ params,
+ nodeType,
+ language,
+ node?.id,
+ node?.type,
+ verboseSchema,
+ instanceId,
+ request,
+ patchParams,
+ updateParam,
+ t,
+ ]);
+
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
@@ -219,78 +482,88 @@ export const NodeConfigPanel: React.FC = ({ node,
{requiredErrors.map((e) => e.paramLabel).join(', ')}
)}
- {parameters.map((param: NodeTypeParameter) => {
- // Safety net: hidden params have no UI footprint at all — no row,
- // no required-mark, no type-badge. Their value is system-set.
- if (param.frontendType === 'hidden') return null;
- const useRequiredPicker = _shouldUseRequiredPicker(param);
- if (useRequiredPicker) {
+ {extractContentAccordionItems !== null ? (
+
+ key={`${node.id}-extract-accordion`}
+ defaultOpenId={null}
+ items={extractContentAccordionItems}
+ />
+ ) : (
+ parameters.map((param: NodeTypeParameter) => {
+ // Safety net: hidden params have no UI footprint at all — no row,
+ // no required-mark, no type-badge. Their value is system-set.
+ if (param.frontendType === 'hidden') return null;
+ if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
+ const useRequiredPicker = _shouldUseRequiredPicker(param);
+ if (useRequiredPicker) {
+ return (
+
+ updateParam(param.name, val)}
+ />
+
+ );
+ }
+ const frontendType = param.frontendType || 'text';
+ const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
-
-
updateParam(param.name, val)}
+
+
+ {param.required && (
+
+ *
+
+ )}
+ {verboseSchema && param.type && (
+
+ {param.type}
+
+ )}
+
+
updateParam(param.name, val)}
+ allParams={params}
+ instanceId={instanceId}
+ request={request}
+ nodeType={node.type}
+ onPatchParams={patchParams}
/>
);
- }
- const frontendType = param.frontendType || 'text';
- const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
- return (
-
-
- {param.required && (
-
- *
-
- )}
- {verboseSchema && param.type && (
-
- {param.type}
-
- )}
-
-
updateParam(param.name, val)}
- allParams={params}
- instanceId={instanceId}
- request={request}
- nodeType={node.type}
- />
-
- );
- })}
+ })
+ )}
);
};
@@ -320,6 +593,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance',
'sharepointFolder',
'sharepointFile',
+ 'userFileFolder',
'clickupList',
'clickupTask',
'dataRef',
diff --git a/src/components/FlowEditor/editor/NodeSidebar.tsx b/src/components/FlowEditor/editor/NodeSidebar.tsx
index d175e17..8369884 100644
--- a/src/components/FlowEditor/editor/NodeSidebar.tsx
+++ b/src/components/FlowEditor/editor/NodeSidebar.tsx
@@ -1,6 +1,6 @@
/**
* NodeSidebar - Sidebar with searchable, collapsible node list.
- * Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
+ * Groups node types by category (start, input, flow, data, ai, email, sharepoint).
*/
import React, { useMemo } from 'react';
@@ -21,7 +21,7 @@ interface NodeSidebarProps {
language: string;
expandedCategories: Set;
onToggleCategory: (id: string) => void;
- /** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
+ /** Hide palette categories (optional; e.g. feature flags) */
excludedCategories?: Set;
style?: React.CSSProperties;
}
diff --git a/src/components/FlowEditor/editor/WorkflowConfigurationModal.tsx b/src/components/FlowEditor/editor/WorkflowConfigurationModal.tsx
deleted file mode 100644
index b615961..0000000
--- a/src/components/FlowEditor/editor/WorkflowConfigurationModal.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * Workflow configuration — primary start kind drives the canvas start node.
- */
-
-import React, { useState, useEffect } from 'react';
-import type { WorkflowEntryPoint } from '../../../api/workflowApi';
-import {
- getPrimaryStartKind,
- buildInvocationsForPrimaryKind,
-} from '../nodes/runtime/workflowStartSync';
-import styles from './Automation2FlowEditor.module.css';
-
-import { useLanguage } from '../../../providers/language/LanguageContext';
-
-/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
-function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
- return [
- { value: 'manual', label: t('Manueller Trigger') },
- { value: 'form', label: t('Formular') },
- { value: 'schedule', label: t('Zeitplan') },
- { value: 'always_on', label: t('Immer aktiv') },
- ];
-}
-
-interface WorkflowConfigurationModalProps {
- open: boolean;
- onClose: () => void;
- invocations: WorkflowEntryPoint[];
- onApply: (next: WorkflowEntryPoint[]) => void;
-}
-
-const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
-
-function normalizeLoadedKind(k: string): string {
- if (_validKinds.includes(k)) return k;
- if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
- if (k === 'api') return 'manual';
- return 'manual';
-}
-
-export const WorkflowConfigurationModal: React.FC = ({ open,
- onClose,
- invocations,
- onApply,
-}) => {
- const { t } = useLanguage();
- const kindOptions = _getKindOptions(t);
- const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
- const [titleDe, setTitleDe] = useState('');
-
- useEffect(() => {
- if (!open) return;
- const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
- setKind(k);
- const entry = invocations[0];
- const entryTitle = entry?.title;
- if (typeof entryTitle === 'string') setTitleDe(entryTitle);
- else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
- else setTitleDe('');
- }, [open, invocations]);
-
- if (!open) return null;
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- const label =
- titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
- const next = buildInvocationsForPrimaryKind(kind, invocations, label);
- onApply(next);
- onClose();
- };
-
- return (
-
-
-
- {t('Workflow-Konfiguration')}
-
-
- {t(
- 'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
- )}
-
-
-
-
- );
-};
diff --git a/src/components/FlowEditor/index.ts b/src/components/FlowEditor/index.ts
index 2605f3e..6439279 100644
--- a/src/components/FlowEditor/index.ts
+++ b/src/components/FlowEditor/index.ts
@@ -1,11 +1,12 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
-export { FlowCanvas } from './editor/FlowCanvas';
-export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
+export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
+export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
+export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';
diff --git a/src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx b/src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
new file mode 100644
index 0000000..ff3cf92
--- /dev/null
+++ b/src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
@@ -0,0 +1,104 @@
+/**
+ * One text field per option — the text the end user sees in the dropdown.
+ * Stored as { value, label } with the same string so payload and UI stay in sync.
+ */
+
+import React from 'react';
+import { FaTimes } from 'react-icons/fa';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+import type { FormFieldOptionRow } from './formFieldOptionsUtils';
+
+export interface FormFieldOptionsEditorProps {
+ options: FormFieldOptionRow[];
+ onChange: (next: FormFieldOptionRow[]) => void;
+ className?: string;
+ rowClassName?: string;
+}
+
+export const FormFieldOptionsEditor: React.FC = ({
+ options,
+ onChange,
+ className,
+ rowClassName,
+}) => {
+ const { t } = useLanguage();
+ const rootClass = className ?? '';
+ const lineClass = rowClassName ?? '';
+
+ const setOptionText = (idx: number, text: string) => {
+ const next = options.map((o, i) =>
+ i === idx ? { value: text, label: text } : o,
+ );
+ onChange(next);
+ };
+
+ return (
+
+
+ {t('Auswahloptionen')}
+
+ {options.map((opt, idx) => (
+
+ setOptionText(idx, e.target.value)}
+ style={{
+ flex: '1 1 120px',
+ minWidth: 80,
+ padding: '4px 6px',
+ fontSize: '0.8rem',
+ borderRadius: 4,
+ border: '1px solid var(--border-color, #ddd)',
+ boxSizing: 'border-box',
+ }}
+ />
+ onChange(options.filter((_, i) => i !== idx))}
+ style={{
+ padding: '4px 8px',
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--text-tertiary, #999)',
+ cursor: 'pointer',
+ borderRadius: 4,
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+
+
+
+ ))}
+
onChange([...options, { value: '', label: '' }])}
+ style={{
+ marginTop: 2,
+ padding: '4px 10px',
+ fontSize: '0.75rem',
+ borderRadius: 4,
+ border: '1px dashed var(--border-color, #bbb)',
+ background: 'var(--bg-primary, #fff)',
+ color: 'var(--text-secondary, #555)',
+ cursor: 'pointer',
+ }}
+ >
+ + {t('Option')}
+
+
+ );
+};
diff --git a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
index d0a07f6..474e101 100644
--- a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
@@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
+import {
+ deriveFormFieldPayloadKey,
+ formFieldTypeHasConfigurableOptions,
+ normalizeFormFieldOptions,
+} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC = ({ params, upda
{
- const next = [...fields];
- next[i] = { ...next[i], name: e.target.value };
- updateParam('fields', next);
- }}
- />
- {
+ const label = e.target.value;
const next = [...fields];
- next[i] = { ...next[i], label: e.target.value };
+ next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next);
}}
/>
@@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC = ({ params, upda
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
- next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
+ const type = e.target.value as FormField['type'];
+ const row: FormField = { ...f, type };
+ if (formFieldTypeHasConfigurableOptions(type)) {
+ row.options = normalizeFormFieldOptions(row.options);
+ }
+ next[i] = row;
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
@@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC = ({ params, upda
+ {formFieldTypeHasConfigurableOptions(f.type) ? (
+ {
+ const next = [...fields];
+ next[i] = { ...next[i], options: opts };
+ updateParam('fields', next);
+ }}
+ />
+ ) : null}
))}
- updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
+ updateParam('fields', [
+ ...fields,
+ {
+ name: deriveFormFieldPayloadKey('', fields.length),
+ type: 'text',
+ label: '',
+ required: false,
+ },
+ ])
}
>
+ {t('Feld')}
diff --git a/src/components/FlowEditor/nodes/form/formFieldOptionsUtils.ts b/src/components/FlowEditor/nodes/form/formFieldOptionsUtils.ts
new file mode 100644
index 0000000..8d7b9e6
--- /dev/null
+++ b/src/components/FlowEditor/nodes/form/formFieldOptionsUtils.ts
@@ -0,0 +1,40 @@
+/**
+ * Helpers for optional select/multiselect rows on workflow form field definitions.
+ */
+
+export type FormFieldOptionRow = { value: string; label: string };
+
+/** Field types where the author defines explicit { value, label } choices. */
+export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
+ if (!typeId) return false;
+ return typeId === 'select' || typeId === 'enum';
+}
+
+export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((o, i) => {
+ if (o && typeof o === 'object' && !Array.isArray(o)) {
+ const r = o as Record;
+ const value = String(r.value ?? r.id ?? '');
+ const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
+ return { value, label };
+ }
+ const s = String(o ?? '');
+ return { value: s, label: s };
+ });
+}
+
+/**
+ * Stable key for `payload.*` / data refs. From the visible label; empty label → `field_`.
+ */
+export function deriveFormFieldPayloadKey(label: string, index: number): string {
+ const trimmed = label.trim();
+ if (!trimmed) return `field_${index + 1}`;
+ const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
+ let s = deaccent
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ if (!s) return `field_${index + 1}`;
+ return s;
+}
diff --git a/src/components/FlowEditor/nodes/form/index.ts b/src/components/FlowEditor/nodes/form/index.ts
index 6d9b94e..162018d 100644
--- a/src/components/FlowEditor/nodes/form/index.ts
+++ b/src/components/FlowEditor/nodes/form/index.ts
@@ -1 +1,8 @@
export { FormNodeConfig } from './FormNodeConfig';
+export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
+export type { FormFieldOptionRow } from './formFieldOptionsUtils';
+export {
+ deriveFormFieldPayloadKey,
+ formFieldTypeHasConfigurableOptions,
+ normalizeFormFieldOptions,
+} from './formFieldOptionsUtils';
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx
new file mode 100644
index 0000000..a6722ba
--- /dev/null
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx
@@ -0,0 +1,274 @@
+/**
+ * Backend-driven case list for flow.switch (depends on value dataRef).
+ */
+
+import React from 'react';
+import type { FieldRendererProps } from './index';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { isRef, type DataRef } from '../shared/dataRef';
+import { toApiGraph } from '../shared/graphUtils';
+import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+
+export interface SwitchCase {
+ operator: string;
+ value?: string | number | boolean;
+}
+
+function normalizeCase(c: unknown): SwitchCase {
+ if (c && typeof c === 'object' && 'operator' in (c as object)) {
+ const o = c as SwitchCase;
+ const v = o.value;
+ const safeValue: string | number | boolean | undefined =
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
+ return { operator: o.operator ?? 'eq', value: safeValue };
+ }
+ const fallbackValue: string | number | boolean | undefined =
+ typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
+ return { operator: 'eq', value: fallbackValue };
+}
+
+function operatorsFromCatalog(
+ catalog: Record | undefined,
+ valueKind: string
+): ConditionOperatorDef[] {
+ if (!catalog) return [];
+ return catalog[valueKind] ?? catalog.unknown ?? [];
+}
+
+function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
+ if (!operators.length) return cases;
+ return cases.map((c) => {
+ const op = operators.find((o) => o.id === c.operator) ?? operators[0];
+ return {
+ operator: op.id,
+ value: op.needsValue ? c.value ?? '' : undefined,
+ };
+ });
+}
+
+function CaseValueInput({
+ caseItem,
+ opDef,
+ valueKind,
+ onChange,
+ t,
+}: {
+ caseItem: SwitchCase;
+ opDef: ConditionOperatorDef | undefined;
+ valueKind: string;
+ onChange: (v: string | number) => void;
+ t: (key: string) => string;
+}) {
+ const valueInput = opDef?.valueInput;
+ const val = caseItem.value;
+
+ if (
+ valueInput?.kind === 'select' ||
+ valueInput?.kind === 'contentType' ||
+ valueInput?.kind === 'outputMode' ||
+ valueInput?.kind === 'language' ||
+ valueInput?.kind === 'mime'
+ ) {
+ return (
+ onChange(e.target.value)}
+ style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {t('— wählen —')}
+ {(valueInput.options ?? []).map((opt) => (
+
+ {opt}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ onChange(
+ valueInput?.kind === 'number' || valueKind === 'number'
+ ? parseFloat(e.target.value) || 0
+ : e.target.value
+ )
+ }
+ placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
+ style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ />
+ );
+}
+
+export const CaseListEditor: React.FC = ({
+ param,
+ value,
+ onChange,
+ allParams,
+}) => {
+ const { t } = useLanguage();
+ const dataFlow = useAutomation2DataFlow();
+ const dependsOn =
+ param.frontendOptions && typeof param.frontendOptions === 'object'
+ ? String((param.frontendOptions as Record).dependsOn ?? 'value')
+ : 'value';
+
+ const valueParam = allParams?.[dependsOn];
+ const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
+
+ const rawCases = Array.isArray(value) ? value : [];
+ const cases: SwitchCase[] = rawCases.map(normalizeCase);
+
+ const [operators, setOperators] = React.useState([]);
+ const [valueKind, setValueKind] = React.useState('unknown');
+ const [loading, setLoading] = React.useState(false);
+ const catalog = dataFlow?.conditionOperatorCatalog;
+
+ React.useEffect(() => {
+ if (!ref) {
+ const ops = operatorsFromCatalog(catalog, 'unknown');
+ setOperators(ops);
+ setValueKind('unknown');
+ return;
+ }
+
+ let cancelled = false;
+
+ const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
+ if (cancelled) return;
+ setValueKind(vk);
+ setOperators(ops);
+ if (cases.length > 0) {
+ const next = sanitizeCases(cases, ops);
+ if (JSON.stringify(next) !== JSON.stringify(cases)) {
+ onChange(next);
+ }
+ }
+ };
+
+ if (dataFlow?.instanceId && dataFlow.request) {
+ setLoading(true);
+ fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
+ graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
+ nodeId: dataFlow.currentNodeId,
+ ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
+ })
+ .then((meta) => applyMeta(meta.valueKind, meta.operators))
+ .catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ } else {
+ applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
+
+ const setCases = (next: SwitchCase[]) => onChange(next);
+
+ const addCase = () => {
+ const opDef = operators[0];
+ setCases([
+ ...cases,
+ {
+ operator: opDef?.id ?? 'eq',
+ value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
+ },
+ ]);
+ };
+
+ if (!ref) {
+ return (
+
+
+ {param.description || param.name}
+
+
+ {t('Zuerst einen Wert im Data Picker wählen')}
+
+
+ );
+ }
+
+ return (
+
+
+ {param.description || param.name}
+
+ {loading && (
+
+ {t('Lade Operatoren…')}
+
+ )}
+ {cases.map((c, i) => {
+ const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
+ const needsValue = opDef?.needsValue ?? true;
+ return (
+
+ {
+ const op = operators.find((o) => o.id === e.target.value);
+ const next = [...cases];
+ next[i] = {
+ operator: e.target.value,
+ value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
+ };
+ setCases(next);
+ }}
+ disabled={loading || operators.length === 0}
+ style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {operators.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ {needsValue && (
+ {
+ const next = [...cases];
+ next[i] = { ...next[i], value: v };
+ setCases(next);
+ }}
+ />
+ )}
+ setCases(cases.filter((_, j) => j !== i))}
+ style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
+ >
+ ×
+
+
+ );
+ })}
+
+ {t('Fall hinzufügen')}
+
+
+ );
+};
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx
new file mode 100644
index 0000000..ff23dfc
--- /dev/null
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx
@@ -0,0 +1,223 @@
+/**
+ * Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
+ */
+
+import React from 'react';
+import type { FieldRendererProps } from './index';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { isRef, type DataRef } from '../shared/dataRef';
+import { toApiGraph } from '../shared/graphUtils';
+import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+
+export interface StructuredCondition {
+ type: 'condition';
+ operator: string;
+ value?: string | number;
+ /** Legacy — ignored when Item is set */
+ ref?: DataRef | null;
+}
+
+function parseCondition(v: unknown): StructuredCondition {
+ if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
+ const c = v as StructuredCondition;
+ return { type: 'condition', operator: c.operator ?? 'eq', value: c.value };
+ }
+ return { type: 'condition', operator: 'eq', value: '' };
+}
+
+function operatorsFromCatalog(
+ catalog: Record | undefined,
+ valueKind: string
+): ConditionOperatorDef[] {
+ if (!catalog) return [];
+ return catalog[valueKind] ?? catalog.unknown ?? [];
+}
+
+export const ConditionEditor: React.FC = ({
+ param,
+ value,
+ onChange,
+ allParams,
+}) => {
+ const { t } = useLanguage();
+ const dataFlow = useAutomation2DataFlow();
+ const dependsOn =
+ param.frontendOptions && typeof param.frontendOptions === 'object'
+ ? String((param.frontendOptions as Record).dependsOn ?? 'Item')
+ : 'Item';
+
+ const itemRef = allParams?.[dependsOn];
+ const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
+
+ const cond = parseCondition(value);
+ const [operators, setOperators] = React.useState([]);
+ const [valueKind, setValueKind] = React.useState('unknown');
+ const [loading, setLoading] = React.useState(false);
+
+ const catalog = dataFlow?.conditionOperatorCatalog;
+
+ React.useEffect(() => {
+ if (!ref) {
+ setOperators([]);
+ setValueKind('unknown');
+ return;
+ }
+
+ let cancelled = false;
+
+ const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
+ if (cancelled) return;
+ setValueKind(vk);
+ setOperators(ops);
+ const valid = ops.some((o) => o.id === cond.operator);
+ if (!valid && ops.length > 0) {
+ const first = ops[0];
+ onChange({
+ type: 'condition',
+ operator: first.id,
+ value: first.needsValue ? cond.value ?? '' : undefined,
+ });
+ }
+ };
+
+ if (dataFlow?.instanceId && dataFlow.request) {
+ setLoading(true);
+ fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
+ graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
+ nodeId: dataFlow.currentNodeId,
+ ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
+ })
+ .then((meta) => {
+ applyMeta(meta.valueKind, meta.operators);
+ })
+ .catch(() => {
+ const ops = operatorsFromCatalog(catalog, 'unknown');
+ applyMeta('unknown', ops);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ } else {
+ const ops = operatorsFromCatalog(catalog, 'unknown');
+ applyMeta('unknown', ops);
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
+ }, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
+
+ const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
+ const needsValue = currentOp?.needsValue ?? true;
+ const valueInput = currentOp?.valueInput;
+
+ const setCondition = (next: StructuredCondition) => {
+ onChange(next);
+ };
+
+ if (!ref) {
+ return (
+
+
+ {param.description || param.name}
+
+
+ {t('Zuerst ein Item im Data Picker wählen')}
+
+
+ );
+ }
+
+ const handleOperatorChange = (opId: string) => {
+ const opDef = operators.find((o) => o.id === opId);
+ setCondition({
+ type: 'condition',
+ operator: opId,
+ value: opDef?.needsValue ? cond.value ?? '' : undefined,
+ });
+ };
+
+ const handleValueChange = (v: string | number) => {
+ const kind = valueInput?.kind;
+ const parsed =
+ kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
+ setCondition({ type: 'condition', operator: cond.operator, value: parsed });
+ };
+
+ return (
+
+
+ {param.description || param.name}
+
+
+ {t('Vergleich')}
+ handleOperatorChange(e.target.value)}
+ disabled={loading || operators.length === 0}
+ style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {operators.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ {loading && (
+
{t('Lade Operatoren…')}
+ )}
+ {needsValue && (
+
+ {t('Wert')}
+ {valueInput?.kind === 'select' ||
+ valueInput?.kind === 'contentType' ||
+ valueInput?.kind === 'outputMode' ||
+ valueInput?.kind === 'language' ||
+ valueInput?.kind === 'mime' ? (
+ handleValueChange(e.target.value)}
+ style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {t('— wählen —')}
+ {(valueInput.options ?? []).map((opt) => (
+
+ {opt}
+
+ ))}
+
+ ) : (
+
+ handleValueChange(
+ valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
+ )
+ }
+ placeholder={
+ valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
+ }
+ style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ />
+ )}
+
+ )}
+
+ );
+};
+
+const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+ {children}
+
+);
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextAssignmentsEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextAssignmentsEditor.tsx
new file mode 100644
index 0000000..da2c0ca
--- /dev/null
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextAssignmentsEditor.tsx
@@ -0,0 +1,372 @@
+/**
+ * One place to configure context.setContext rows: target key, then either
+ * upstream picker, a fixed literal, or a human task.
+ */
+
+import React from 'react';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { DataPicker } from '../shared/DataPicker';
+import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
+import type { FieldRendererProps } from './index';
+
+type ValueSource = 'pickUpstream' | 'literal' | 'humanTask';
+
+export interface ContextAssignmentRow {
+ contextKey: string;
+ valueSource: ValueSource;
+ /** Single resolved ref (server resolves { type: ref } to a value). */
+ upstreamRef?: DataRef | SystemVarRef | null;
+ /** Optional dotted path under the picked value, or under the wire payload (expert). */
+ sourcePath?: string;
+ literal?: string;
+ taskTitle?: string;
+ taskDescription?: string;
+ mode?: 'set' | 'setIfEmpty' | 'append' | 'increment';
+ valueType?: string;
+}
+
+function defaultRow(): ContextAssignmentRow {
+ return {
+ contextKey: '',
+ valueSource: 'literal',
+ literal: '',
+ mode: 'set',
+ valueType: 'str',
+ };
+}
+
+function legacyEntryToRow(
+ e: Record,
+ globalPick: unknown,
+): ContextAssignmentRow {
+ const am = String(e.assignmentMode || 'direct');
+ let valueSource: ValueSource = 'literal';
+ if (am === 'fromUpstream') valueSource = 'pickUpstream';
+ else if (am === 'humanTask') valueSource = 'humanTask';
+
+ const sourcePathStr = typeof e.sourcePath === 'string' ? e.sourcePath : '';
+ let upstream: DataRef | SystemVarRef | undefined;
+ if (isRef(e.upstreamRef) || isSystemVar(e.upstreamRef)) {
+ upstream = e.upstreamRef as DataRef | SystemVarRef;
+ } else if (
+ am === 'fromUpstream' &&
+ !sourcePathStr.trim() &&
+ (isRef(globalPick) || isSystemVar(globalPick))
+ ) {
+ upstream = globalPick as DataRef | SystemVarRef;
+ }
+
+ return {
+ contextKey: typeof e.contextKey === 'string' ? e.contextKey : typeof e.key === 'string' ? e.key : '',
+ valueSource,
+ upstreamRef: upstream,
+ sourcePath: sourcePathStr,
+ literal: e.literal != null ? String(e.literal) : e.value != null ? String(e.value) : '',
+ taskTitle: typeof e.taskTitle === 'string' ? e.taskTitle : '',
+ taskDescription: typeof e.taskDescription === 'string' ? e.taskDescription : '',
+ mode: (e.mode as ContextAssignmentRow['mode']) || 'set',
+ valueType: typeof e.valueType === 'string' ? e.valueType : typeof e.type === 'string' ? e.type : 'str',
+ };
+}
+
+function normalizeRows(raw: unknown, allParams?: Record): ContextAssignmentRow[] {
+ if (Array.isArray(raw) && raw.length > 0) {
+ return raw.map((r) => {
+ if (!r || typeof r !== 'object') return defaultRow();
+ const o = r as Record;
+ let valueSource = o.valueSource as ValueSource | undefined;
+ if (!valueSource && o.assignmentMode === 'fromUpstream') valueSource = 'pickUpstream';
+ else if (!valueSource && o.assignmentMode === 'humanTask') valueSource = 'humanTask';
+ else if (!valueSource) valueSource = 'literal';
+ return {
+ contextKey: typeof o.contextKey === 'string' ? o.contextKey : typeof o.key === 'string' ? o.key : '',
+ valueSource,
+ upstreamRef: (isRef(o.upstreamRef) || isSystemVar(o.upstreamRef) ? o.upstreamRef : undefined) as
+ | DataRef
+ | SystemVarRef
+ | undefined,
+ sourcePath: typeof o.sourcePath === 'string' ? o.sourcePath : '',
+ literal: o.literal != null ? String(o.literal) : o.value != null ? String(o.value) : '',
+ taskTitle: typeof o.taskTitle === 'string' ? o.taskTitle : '',
+ taskDescription: typeof o.taskDescription === 'string' ? o.taskDescription : '',
+ mode: (o.mode as ContextAssignmentRow['mode']) || 'set',
+ valueType: typeof o.valueType === 'string' ? o.valueType : typeof o.type === 'string' ? o.type : 'str',
+ };
+ });
+ }
+
+ const g = allParams;
+ if (g && Array.isArray(g.entries) && g.entries.length > 0) {
+ const globalPick = g.upstreamPick;
+ return (g.entries as Record[]).map((e) => legacyEntryToRow(e, globalPick));
+ }
+ if (g) {
+ const tk = String(g.targetKey || '').trim();
+ const globalPick = g.upstreamPick;
+ if (
+ tk &&
+ globalPick !== undefined &&
+ globalPick !== null &&
+ !(typeof globalPick === 'string' && !globalPick.trim()) &&
+ !(typeof globalPick === 'object' && globalPick !== null && Object.keys(globalPick).length === 0)
+ ) {
+ const ups =
+ isRef(globalPick) || isSystemVar(globalPick) ? (globalPick as DataRef | SystemVarRef) : undefined;
+ return [
+ {
+ contextKey: tk,
+ valueSource: 'pickUpstream' as const,
+ upstreamRef: ups,
+ sourcePath: '',
+ literal: '',
+ taskTitle: '',
+ taskDescription: '',
+ mode: 'set',
+ valueType: 'str',
+ },
+ ];
+ }
+ }
+
+ return [defaultRow()];
+}
+
+const MODES: Array<{ id: NonNullable; labelDe: string }> = [
+ { id: 'set', labelDe: 'setzen' },
+ { id: 'setIfEmpty', labelDe: 'setzen wenn leer' },
+ { id: 'append', labelDe: 'anhängen' },
+ { id: 'increment', labelDe: 'addieren' },
+];
+
+const TYPES = ['str', 'int', 'float', 'bool', 'object', 'list'] as const;
+
+const ROW_BOX: React.CSSProperties = {
+ border: '1px solid #ddd',
+ borderRadius: 6,
+ padding: 8,
+ marginBottom: 8,
+ background: '#fafafa',
+};
+
+const CHIP_STYLE: React.CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 6,
+ padding: '4px 8px',
+ background: '#eaf6e8',
+ border: '1px solid #5cb85c',
+ borderRadius: 4,
+ fontSize: 12,
+ marginTop: 4,
+};
+
+const REMOVE_BTN: React.CSSProperties = {
+ padding: '0 6px',
+ border: '1px solid #5cb85c',
+ borderRadius: 3,
+ background: '#fff',
+ color: '#3c763d',
+ cursor: 'pointer',
+ fontSize: 11,
+ marginLeft: 'auto',
+};
+
+export const ContextAssignmentsEditor: React.FC = ({ param, value, onChange, allParams }) => {
+ const { t } = useLanguage();
+ const dataFlow = useAutomation2DataFlow();
+ const rows = normalizeRows(value, allParams);
+ const [pickerRow, setPickerRow] = React.useState(null);
+
+ const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
+ const hasSources = sourceIds.some((id) => {
+ const n = dataFlow?.nodes.find((x) => x.id === id);
+ return n?.type !== 'trigger.manual';
+ });
+
+ const setRows = (next: ContextAssignmentRow[]) => {
+ onChange(next.length ? next : [defaultRow()]);
+ };
+
+ const setRow = (idx: number, patch: Partial) => {
+ const next = [...rows];
+ next[idx] = { ...next[idx], ...patch };
+ setRows(next);
+ };
+
+ const addRow = () => setRows([...rows, defaultRow()]);
+
+ const removeRow = (idx: number) => {
+ if (rows.length <= 1) {
+ onChange([defaultRow()]);
+ return;
+ }
+ setRows(rows.filter((_, i) => i !== idx));
+ };
+
+ const labelForRef = (ref: DataRef | SystemVarRef): string => {
+ if (isSystemVar(ref)) {
+ return t('System') + `: ${ref.variable}`;
+ }
+ const nodeLabel =
+ dataFlow?.getNodeLabel(
+ dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
+ ) ?? ref.nodeId;
+ const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
+ return pathStr ? `${nodeLabel} → ${pathStr}` : nodeLabel;
+ };
+
+ const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
+ if (!isRef(picked) && !isSystemVar(picked)) return;
+ setRow(idx, { upstreamRef: picked });
+ setPickerRow(null);
+ };
+
+ return (
+
+
+ {param.description || param.name}
+ {param.required && * }
+
+
+ {rows.map((row, idx) => (
+
+
+ setRow(idx, { contextKey: e.target.value })}
+ style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ />
+ {
+ const vs = e.target.value as ValueSource;
+ const patch: Partial = { valueSource: vs };
+ if (vs === 'literal') patch.upstreamRef = undefined;
+ if (vs === 'pickUpstream') patch.literal = '';
+ setRow(idx, patch);
+ }}
+ style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {t('Wert aus Daten-Picker')}
+ {t('Fester Wert')}
+ {t('Benutzer setzt Wert (Task)')}
+
+ setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
+ style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {MODES.map((m) => (
+
+ {m.labelDe}
+
+ ))}
+
+ setRow(idx, { valueType: e.target.value })}
+ style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ >
+ {TYPES.map((tp) => (
+
+ {tp}
+
+ ))}
+
+ removeRow(idx)}>
+ ×
+
+
+
+ {row.valueSource === 'pickUpstream' && (
+
+ {row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
+
+ {labelForRef(row.upstreamRef)}
+ setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
+ ×
+
+
+ )}
+
setPickerRow(idx)}
+ disabled={!hasSources}
+ style={{
+ marginTop: 4,
+ width: '100%',
+ padding: '4px 8px',
+ borderRadius: 4,
+ border: '1px solid #1c5fb5',
+ background: hasSources ? '#fff' : '#f5f5f5',
+ color: hasSources ? '#1c5fb5' : '#999',
+ cursor: hasSources ? 'pointer' : 'not-allowed',
+ fontSize: 12,
+ textAlign: 'left',
+ }}
+ >
+ {hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
+
+
setRow(idx, { sourcePath: e.target.value })}
+ style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
+ />
+
+ )}
+
+ {row.valueSource === 'literal' && (
+
setRow(idx, { literal: e.target.value })}
+ style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ />
+ )}
+
+ {row.valueSource === 'humanTask' && (
+
+ setRow(idx, { taskTitle: e.target.value })}
+ style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
+ />
+
+ )}
+
+ ))}
+
+
+ {t('Zuweisung hinzufügen')}
+
+
+ {dataFlow && pickerRow != null && (
+
setPickerRow(null)}
+ onPick={(picked) => onPickRef(pickerRow, picked)}
+ availableSourceIds={sourceIds}
+ nodes={dataFlow.nodes}
+ nodeOutputsPreview={dataFlow.nodeOutputsPreview}
+ getNodeLabel={dataFlow.getNodeLabel}
+ expectedParamType="Any"
+ />
+ )}
+
+ );
+};
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx
new file mode 100644
index 0000000..5a8655f
--- /dev/null
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx
@@ -0,0 +1,267 @@
+/**
+ * userFileFolder — FormGeneratorTree embedded: combobox-style trigger + expandable tree.
+ */
+
+import React, { useMemo, useCallback, useState, useEffect } from 'react';
+import { FaFolderPlus } from 'react-icons/fa';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+import { usePrompt } from '../../../../hooks/usePrompt';
+import { getFolderTree, createFolder } from '../../../../api/fileApi';
+import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
+import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
+import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
+import type { FieldRendererProps } from './index';
+
+export const UserFileFolderPicker: React.FC = ({ param, value, onChange, request }) => {
+ const { t } = useLanguage();
+ const { prompt, PromptDialog } = usePrompt();
+ const [panelOpen, setPanelOpen] = useState(false);
+ /** Remount embedded tree after create/rename elsewhere */
+ const [treeRefreshKey, setTreeRefreshKey] = useState(0);
+ const [creating, setCreating] = useState(false);
+ /** Display name for saved folderId (resolved from API when graph loads). */
+ const [pickedName, setPickedName] = useState(null);
+
+ const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
+
+ const strVal = typeof value === 'string' ? value : '';
+ const rootSelected = strVal === '';
+
+ useEffect(() => {
+ if (!strVal) {
+ setPickedName(null);
+ return;
+ }
+ if (!request) return;
+ let cancelled = false;
+ getFolderTree(request, 'me')
+ .then((folders) => {
+ if (cancelled) return;
+ const f = folders.find((x) => x.id === strVal);
+ setPickedName(f?.name ?? null);
+ })
+ .catch(() => {
+ if (!cancelled) setPickedName(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [strVal, request]);
+
+ const handleNodeClick = useCallback(
+ (node: TreeNode) => {
+ if (node.type === 'folder') {
+ setPickedName(node.name);
+ onChange(node.id);
+ setPanelOpen(false);
+ }
+ },
+ [onChange],
+ );
+
+ const clearFolder = useCallback(() => {
+ onChange('');
+ setPickedName(null);
+ }, [onChange]);
+
+ const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
+
+ const handleCreateFolder = useCallback(async () => {
+ if (!request || creating) return;
+ const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
+ const entered = await prompt(`Ordnername${parentHint}:`, {
+ title: 'Neuer Ordner',
+ placeholder: 'Ordnername',
+ confirmLabel: t('Anlegen'),
+ });
+ const trimmed = entered?.trim();
+ if (!trimmed) return;
+ setCreating(true);
+ try {
+ const parentId = strVal || null;
+ const folder = await createFolder(request, trimmed, parentId);
+ setPickedName(folder.name);
+ onChange(folder.id);
+ setTreeRefreshKey((k) => k + 1);
+ } catch {
+ // stay silent in minimal UI; devtools / global handler may log
+ } finally {
+ setCreating(false);
+ }
+ }, [request, creating, strVal, pickedName, prompt, onChange, t]);
+
+ return (
+
+
{param.description || param.name}
+ {!request && (
+
{t('Ordnerliste nicht verfügbar (keine API-Anbindung).')}
+ )}
+ {request && (
+ <>
+
+ setPanelOpen((o) => !o)}
+ style={{
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 8,
+ minWidth: 0,
+ padding: '8px 10px',
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ fontSize: 12,
+ textAlign: 'left',
+ color: 'var(--color-text, #334155)',
+ }}
+ >
+
+ {triggerLabel}
+
+
+ {panelOpen ? '▾' : '▸'}
+
+
+ {strVal ? (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ clearFolder();
+ }}
+ style={{
+ flexShrink: 0,
+ width: 36,
+ border: 'none',
+ borderLeft: '1px solid var(--color-border, #cbd5e1)',
+ background: 'transparent',
+ cursor: 'pointer',
+ fontSize: 16,
+ lineHeight: 1,
+ color: 'var(--color-text-secondary, #64748b)',
+ padding: 0,
+ }}
+ >
+ ×
+
+ ) : null}
+
+
+ {panelOpen && (
+
+
+
{
+ clearFolder();
+ setPanelOpen(false);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ clearFolder();
+ setPanelOpen(false);
+ }
+ }}
+ style={{
+ flex: 1,
+ padding: '8px 12px',
+ fontSize: 12,
+ fontWeight: 600,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ minHeight: 36,
+ background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
+ }}
+ >
+ {t('Stamm — Meine Dateien')}
+
+
{
+ e.stopPropagation();
+ void handleCreateFolder();
+ }}
+ style={{
+ flexShrink: 0,
+ width: 40,
+ minHeight: 36,
+ alignSelf: 'stretch',
+ border: 'none',
+ borderLeft: '1px solid var(--color-border, #e2e8f0)',
+ background: 'transparent',
+ cursor: creating ? 'not-allowed' : 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--primary-color, #2563eb)',
+ opacity: creating ? 0.5 : 1,
+ }}
+ >
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
index 7ac9d92..a2f3071 100644
--- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
@@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
+import {
+ deriveFormFieldPayloadKey,
+ formFieldTypeHasConfigurableOptions,
+ normalizeFormFieldOptions,
+} from '../form/formFieldOptionsUtils';
export interface FieldRendererProps {
param: NodeTypeParameter;
@@ -17,6 +23,10 @@ export interface FieldRendererProps {
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
+ /** Atomically merge several parameter keys (e.g. cron + schedule). */
+ onPatchParams?: (patch: Record) => void;
+ /** Hide the prominent ``param.name`` line (e.g. Accordion header already shows it). */
+ hideAccordionTitle?: boolean;
}
export type FieldRendererComponent = ComponentType;
@@ -26,6 +36,13 @@ export type FieldRendererComponent = ComponentType;
// ---------------------------------------------------------------------------
import React from 'react';
+import { SchedulePlanner } from '../../../SchedulePlanner';
+import {
+ buildCronFromSpec,
+ scheduleSpecFromParams,
+ scheduleSpecToPersistentJson,
+ type ScheduleSpec,
+} from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
@@ -33,7 +50,11 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
+import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
+import { UserFileFolderPicker } from './UserFileFolderPicker';
+import { ConditionEditor } from './ConditionEditor';
+import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
@@ -98,29 +119,145 @@ const DateInput: React.FC = ({ param, value, onChange }) =>