@particle-academy/fancy-flow 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, ComponentType } from 'react';
3
- import { ReactFlowProps, Edge, BackgroundVariant, NodeProps, NodeTypes } from '@xyflow/react';
3
+ import { ReactFlowProps, Edge, BackgroundVariant, Connection, NodeProps, NodeTypes } from '@xyflow/react';
4
4
  import { ConnectionValidatorOptions } from './registry/index.cjs';
5
5
  export { ANY_PORT_TYPE, BUILTIN_KINDS, PortCompatibility, RegistryNode, RichInputAdapter, RichInputPreview, buildNodeTypes, categoryAccent, createConnectionValidator, defaultConfigFor, defaultPortCompatibility, getNodeKind, getRichInputAdapter, isRichInputEnabled, listNodeKinds, onNodeKindsChanged, onRichInputAdapterChanged, registerBuiltinKinds, registerNodeKind, registerRichInputAdapter, resolveNodePorts, resolvePortSpec, validateConfig } from './registry/index.cjs';
6
6
  import { b as FlowNode, F as FlowGraph, e as NodeRunStatus, E as ExecutorRegistry } from './types-sOmpCitB.cjs';
@@ -10,7 +10,7 @@ export { ImportIssue, ImportOptions, ImportResult, WORKFLOW_SCHEMA_URL, WORKFLOW
10
10
  import { N as NodeCategory, a as NodeKindDefinition, C as ConfigField } from './types-D_jOR3Z2.cjs';
11
11
  export { D as DocumentConfigField, K as KeyValueConfigField, P as PortSpec, R as RenderBodyContext, d as RepeaterConfigField, e as RepeaterRowField } from './types-D_jOR3Z2.cjs';
12
12
  import { FlowRunFeedEntry } from './runtime/index.cjs';
13
- export { UseFlowRunOptions, UseFlowRunReturn, UseFlowStateReturn, applyStatusesToNodes, useFlowRun, useFlowState } from './runtime/index.cjs';
13
+ export { HistoryController, UseFlowHistoryReturn, UseFlowRunOptions, UseFlowRunReturn, UseFlowStateReturn, applyStatusesToNodes, createHistory, useFlowHistory, useFlowRun, useFlowState } from './runtime/index.cjs';
14
14
  export { R as RunOptions, a as RunResult, r as runFlow } from './run-flow-VKm57Hcj.cjs';
15
15
  export { L as LEGACY_PAUSE_PREFIXES, a as PAUSE_PREFIX, P as PauseAwaiting, b as PauseSignal, d as decodePause, e as encodePause, i as isPause, p as pauseForHuman } from './pause-9iT4tCEV.cjs';
16
16
  import './capabilities-ChE3Xi8R.cjs';
@@ -50,6 +50,33 @@ type FlowCanvasProps = Omit<ReactFlowProps<FlowNode, Edge>, "nodes" | "edges" |
50
50
  */
51
51
  declare function FlowCanvas({ nodes, edges, background, showControls, showMinimap, height, validateConnections, isValidConnection, toolbar, nodeTypes, edgeTypes, className, style, ...rest }: FlowCanvasProps): react.JSX.Element;
52
52
 
53
+ /**
54
+ * Clone a set of nodes AND the edges internal to that set, remapping every id.
55
+ * This is the piece `duplicateNode` lacks: a copy/paste (or duplicate-selection)
56
+ * that preserves the wiring *between* the copied nodes. Edges with an endpoint
57
+ * outside the set are dropped (they'd dangle). A `parentId` pointing inside the
58
+ * set is remapped; one pointing outside is detached.
59
+ */
60
+ declare function cloneSubgraph(nodes: FlowNode[], edges: Edge[], opts: {
61
+ makeId: () => string;
62
+ offset?: number;
63
+ }): {
64
+ nodes: FlowNode[];
65
+ edges: Edge[];
66
+ idMap: Map<string, string>;
67
+ };
68
+ /**
69
+ * Rewire one edge's endpoint(s) — wraps xyflow's `reconnectEdge`. Keeps the
70
+ * edge's id (`shouldReplaceId: false`) so its label and identity survive the
71
+ * rewire instead of being regenerated from the new endpoints.
72
+ */
73
+ declare function reconnectEdge(edges: Edge[], oldEdge: Edge, newConnection: Connection): Edge[];
74
+ type AlignEdge = "left" | "hcenter" | "right" | "top" | "vcenter" | "bottom";
75
+ /** Align a selection to a shared edge/center of its bounding box. */
76
+ declare function alignNodes(nodes: FlowNode[], edge: AlignEdge): FlowNode[];
77
+ /** Evenly distribute a selection's gaps along an axis (needs 3+ nodes). */
78
+ declare function distributeNodes(nodes: FlowNode[], axis: "h" | "v"): FlowNode[];
79
+
53
80
  /**
54
81
  * Everything the editor knows and can do, handed to every extension point.
55
82
  *
@@ -71,6 +98,10 @@ type FlowEditorApi = {
71
98
  selectedEdgeId: string | null;
72
99
  /** Currently selected edge, or null. */
73
100
  selectedEdge: Edge | null;
101
+ /** All multi-selected node ids (xyflow box / shift selection). */
102
+ selectedIds: string[];
103
+ /** All multi-selected nodes. */
104
+ selectedNodes: FlowNode[];
74
105
  /** True while a run is in flight. */
75
106
  running: boolean;
76
107
  /** Per-node run status, keyed by node id. */
@@ -99,6 +130,21 @@ type FlowEditorApi = {
99
130
  duplicateNode: (id: string) => string | null;
100
131
  /** Replace the whole graph. */
101
132
  setGraph: (graph: FlowGraph) => void;
133
+ /** Duplicate the current multi-selection, preserving edges between them. */
134
+ duplicateSelected: () => void;
135
+ /** Align the multi-selection to a shared edge/center of its bounding box. */
136
+ alignSelected: (edge: AlignEdge) => void;
137
+ /** Evenly distribute the multi-selection's gaps along an axis (needs 3+). */
138
+ distributeSelected: (axis: "h" | "v") => void;
139
+ /** Copy the current selection to the editor clipboard. */
140
+ copy: () => void;
141
+ /** Copy then delete the current selection. */
142
+ cut: () => void;
143
+ /** Paste the clipboard (optionally at a flow position) and select the result. */
144
+ paste: (at?: {
145
+ x: number;
146
+ y: number;
147
+ }) => void;
102
148
  run: () => void;
103
149
  cancel: () => void;
104
150
  reset: () => void;
@@ -109,6 +155,16 @@ type FlowEditorApi = {
109
155
  /** Open a file picker and load a workflow. */
110
156
  importWorkflow: () => void;
111
157
  fitView: () => void;
158
+ /** Undo the last committing edit (add / delete / connect / config / drag /
159
+ * import). Transient interactions (a drag in progress, selection) are not
160
+ * their own steps. */
161
+ undo: () => void;
162
+ /** Redo the last undone edit. */
163
+ redo: () => void;
164
+ /** True when there is an edit to undo. */
165
+ canUndo: boolean;
166
+ /** True when there is an undone edit to redo. */
167
+ canRedo: boolean;
112
168
  };
113
169
  /**
114
170
  * A declarative toolbar button. JSON-friendly on purpose — a host (or an
@@ -134,6 +190,8 @@ type FlowEditorAction = {
134
190
  type FlowEditorBuiltins = {
135
191
  run?: boolean;
136
192
  delete?: boolean;
193
+ /** Undo/redo toolbar buttons. Default true. */
194
+ history?: boolean;
137
195
  /** Right-click a node for Delete / Duplicate. Default true. */
138
196
  contextMenu?: boolean;
139
197
  /** Right-click a connection for Label / Delete. Default true. */
@@ -208,6 +266,17 @@ type FlowEditorProps = {
208
266
  onDelete?: (ids: string[]) => void;
209
267
  /** Called after connections are broken, with the deleted edge ids. */
210
268
  onEdgeDelete?: (ids: string[]) => void;
269
+ /**
270
+ * Stage destructive edits for human confirmation. When set, every delete path
271
+ * — keyboard, panel, context menu, and `api.deleteNodes`/`deleteEdges` —
272
+ * calls this first; return false to veto. Default: delete immediately. This
273
+ * realizes the component contract's "agents propose, humans confirm" on the
274
+ * canvas.
275
+ */
276
+ confirmDelete?: (targets: {
277
+ nodes: FlowNode[];
278
+ edges: Edge[];
279
+ }) => boolean | Promise<boolean>;
211
280
  className?: string;
212
281
  style?: CSSProperties;
213
282
  };
@@ -453,4 +522,4 @@ type FlowRunFeedProps = {
453
522
  /** FlowRunFeed — scrolling log panel. Auto-scrolls to bottom on new entries. */
454
523
  declare function FlowRunFeed({ entries, className, style }: FlowRunFeedProps): react.JSX.Element;
455
524
 
456
- export { ActionNode, ConfigField, ConfigFieldRenderer, type ConfigFieldRendererProps, ConnectionValidatorOptions, DecisionNode, ExecutorRegistry, FlowCanvas, type FlowCanvasProps, FlowEditor, type FlowEditorAction, type FlowEditorApi, type FlowEditorBuiltins, type FlowEditorProps, type FlowEditorSlots, FlowGraph, FlowNode, type FlowNodeRenderProps, FlowRunControls, type FlowRunControlsProps, FlowRunFeed, FlowRunFeedEntry, type FlowRunFeedProps, NodeCategory, NodeConfigPanel, type NodeConfigPanelProps, NodeKindDefinition, NodePalette, type NodePaletteProps, NodePort, type NodePortProps, type NodePortSide, type NodePortType, NodeRunStatus, NodeShell, type NodeShellProps, NoteNode, OutputNode, SubgraphNode, TriggerNode, WorkflowMetadata, WorkflowSchema, defaultNodeTypes, defineNode, paletteDropHandlers, useFlowEditor, useFlowEditorOptional };
525
+ export { ActionNode, type AlignEdge, ConfigField, ConfigFieldRenderer, type ConfigFieldRendererProps, ConnectionValidatorOptions, DecisionNode, ExecutorRegistry, FlowCanvas, type FlowCanvasProps, FlowEditor, type FlowEditorAction, type FlowEditorApi, type FlowEditorBuiltins, type FlowEditorProps, type FlowEditorSlots, FlowGraph, FlowNode, type FlowNodeRenderProps, FlowRunControls, type FlowRunControlsProps, FlowRunFeed, FlowRunFeedEntry, type FlowRunFeedProps, NodeCategory, NodeConfigPanel, type NodeConfigPanelProps, NodeKindDefinition, NodePalette, type NodePaletteProps, NodePort, type NodePortProps, type NodePortSide, type NodePortType, NodeRunStatus, NodeShell, type NodeShellProps, NoteNode, OutputNode, SubgraphNode, TriggerNode, WorkflowMetadata, WorkflowSchema, alignNodes, cloneSubgraph, defaultNodeTypes, defineNode, distributeNodes, paletteDropHandlers, reconnectEdge, useFlowEditor, useFlowEditorOptional };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, ComponentType } from 'react';
3
- import { ReactFlowProps, Edge, BackgroundVariant, NodeProps, NodeTypes } from '@xyflow/react';
3
+ import { ReactFlowProps, Edge, BackgroundVariant, Connection, NodeProps, NodeTypes } from '@xyflow/react';
4
4
  import { ConnectionValidatorOptions } from './registry/index.js';
5
5
  export { ANY_PORT_TYPE, BUILTIN_KINDS, PortCompatibility, RegistryNode, RichInputAdapter, RichInputPreview, buildNodeTypes, categoryAccent, createConnectionValidator, defaultConfigFor, defaultPortCompatibility, getNodeKind, getRichInputAdapter, isRichInputEnabled, listNodeKinds, onNodeKindsChanged, onRichInputAdapterChanged, registerBuiltinKinds, registerNodeKind, registerRichInputAdapter, resolveNodePorts, resolvePortSpec, validateConfig } from './registry/index.js';
6
6
  import { b as FlowNode, F as FlowGraph, e as NodeRunStatus, E as ExecutorRegistry } from './types-sOmpCitB.js';
@@ -10,7 +10,7 @@ export { ImportIssue, ImportOptions, ImportResult, WORKFLOW_SCHEMA_URL, WORKFLOW
10
10
  import { N as NodeCategory, a as NodeKindDefinition, C as ConfigField } from './types-NerkPtHS.js';
11
11
  export { D as DocumentConfigField, K as KeyValueConfigField, P as PortSpec, R as RenderBodyContext, d as RepeaterConfigField, e as RepeaterRowField } from './types-NerkPtHS.js';
12
12
  import { FlowRunFeedEntry } from './runtime/index.js';
13
- export { UseFlowRunOptions, UseFlowRunReturn, UseFlowStateReturn, applyStatusesToNodes, useFlowRun, useFlowState } from './runtime/index.js';
13
+ export { HistoryController, UseFlowHistoryReturn, UseFlowRunOptions, UseFlowRunReturn, UseFlowStateReturn, applyStatusesToNodes, createHistory, useFlowHistory, useFlowRun, useFlowState } from './runtime/index.js';
14
14
  export { R as RunOptions, a as RunResult, r as runFlow } from './run-flow-ChBi6q-W.js';
15
15
  export { L as LEGACY_PAUSE_PREFIXES, a as PAUSE_PREFIX, P as PauseAwaiting, b as PauseSignal, d as decodePause, e as encodePause, i as isPause, p as pauseForHuman } from './pause-9iT4tCEV.js';
16
16
  import './capabilities-DUhAc-EJ.js';
@@ -50,6 +50,33 @@ type FlowCanvasProps = Omit<ReactFlowProps<FlowNode, Edge>, "nodes" | "edges" |
50
50
  */
51
51
  declare function FlowCanvas({ nodes, edges, background, showControls, showMinimap, height, validateConnections, isValidConnection, toolbar, nodeTypes, edgeTypes, className, style, ...rest }: FlowCanvasProps): react.JSX.Element;
52
52
 
53
+ /**
54
+ * Clone a set of nodes AND the edges internal to that set, remapping every id.
55
+ * This is the piece `duplicateNode` lacks: a copy/paste (or duplicate-selection)
56
+ * that preserves the wiring *between* the copied nodes. Edges with an endpoint
57
+ * outside the set are dropped (they'd dangle). A `parentId` pointing inside the
58
+ * set is remapped; one pointing outside is detached.
59
+ */
60
+ declare function cloneSubgraph(nodes: FlowNode[], edges: Edge[], opts: {
61
+ makeId: () => string;
62
+ offset?: number;
63
+ }): {
64
+ nodes: FlowNode[];
65
+ edges: Edge[];
66
+ idMap: Map<string, string>;
67
+ };
68
+ /**
69
+ * Rewire one edge's endpoint(s) — wraps xyflow's `reconnectEdge`. Keeps the
70
+ * edge's id (`shouldReplaceId: false`) so its label and identity survive the
71
+ * rewire instead of being regenerated from the new endpoints.
72
+ */
73
+ declare function reconnectEdge(edges: Edge[], oldEdge: Edge, newConnection: Connection): Edge[];
74
+ type AlignEdge = "left" | "hcenter" | "right" | "top" | "vcenter" | "bottom";
75
+ /** Align a selection to a shared edge/center of its bounding box. */
76
+ declare function alignNodes(nodes: FlowNode[], edge: AlignEdge): FlowNode[];
77
+ /** Evenly distribute a selection's gaps along an axis (needs 3+ nodes). */
78
+ declare function distributeNodes(nodes: FlowNode[], axis: "h" | "v"): FlowNode[];
79
+
53
80
  /**
54
81
  * Everything the editor knows and can do, handed to every extension point.
55
82
  *
@@ -71,6 +98,10 @@ type FlowEditorApi = {
71
98
  selectedEdgeId: string | null;
72
99
  /** Currently selected edge, or null. */
73
100
  selectedEdge: Edge | null;
101
+ /** All multi-selected node ids (xyflow box / shift selection). */
102
+ selectedIds: string[];
103
+ /** All multi-selected nodes. */
104
+ selectedNodes: FlowNode[];
74
105
  /** True while a run is in flight. */
75
106
  running: boolean;
76
107
  /** Per-node run status, keyed by node id. */
@@ -99,6 +130,21 @@ type FlowEditorApi = {
99
130
  duplicateNode: (id: string) => string | null;
100
131
  /** Replace the whole graph. */
101
132
  setGraph: (graph: FlowGraph) => void;
133
+ /** Duplicate the current multi-selection, preserving edges between them. */
134
+ duplicateSelected: () => void;
135
+ /** Align the multi-selection to a shared edge/center of its bounding box. */
136
+ alignSelected: (edge: AlignEdge) => void;
137
+ /** Evenly distribute the multi-selection's gaps along an axis (needs 3+). */
138
+ distributeSelected: (axis: "h" | "v") => void;
139
+ /** Copy the current selection to the editor clipboard. */
140
+ copy: () => void;
141
+ /** Copy then delete the current selection. */
142
+ cut: () => void;
143
+ /** Paste the clipboard (optionally at a flow position) and select the result. */
144
+ paste: (at?: {
145
+ x: number;
146
+ y: number;
147
+ }) => void;
102
148
  run: () => void;
103
149
  cancel: () => void;
104
150
  reset: () => void;
@@ -109,6 +155,16 @@ type FlowEditorApi = {
109
155
  /** Open a file picker and load a workflow. */
110
156
  importWorkflow: () => void;
111
157
  fitView: () => void;
158
+ /** Undo the last committing edit (add / delete / connect / config / drag /
159
+ * import). Transient interactions (a drag in progress, selection) are not
160
+ * their own steps. */
161
+ undo: () => void;
162
+ /** Redo the last undone edit. */
163
+ redo: () => void;
164
+ /** True when there is an edit to undo. */
165
+ canUndo: boolean;
166
+ /** True when there is an undone edit to redo. */
167
+ canRedo: boolean;
112
168
  };
113
169
  /**
114
170
  * A declarative toolbar button. JSON-friendly on purpose — a host (or an
@@ -134,6 +190,8 @@ type FlowEditorAction = {
134
190
  type FlowEditorBuiltins = {
135
191
  run?: boolean;
136
192
  delete?: boolean;
193
+ /** Undo/redo toolbar buttons. Default true. */
194
+ history?: boolean;
137
195
  /** Right-click a node for Delete / Duplicate. Default true. */
138
196
  contextMenu?: boolean;
139
197
  /** Right-click a connection for Label / Delete. Default true. */
@@ -208,6 +266,17 @@ type FlowEditorProps = {
208
266
  onDelete?: (ids: string[]) => void;
209
267
  /** Called after connections are broken, with the deleted edge ids. */
210
268
  onEdgeDelete?: (ids: string[]) => void;
269
+ /**
270
+ * Stage destructive edits for human confirmation. When set, every delete path
271
+ * — keyboard, panel, context menu, and `api.deleteNodes`/`deleteEdges` —
272
+ * calls this first; return false to veto. Default: delete immediately. This
273
+ * realizes the component contract's "agents propose, humans confirm" on the
274
+ * canvas.
275
+ */
276
+ confirmDelete?: (targets: {
277
+ nodes: FlowNode[];
278
+ edges: Edge[];
279
+ }) => boolean | Promise<boolean>;
211
280
  className?: string;
212
281
  style?: CSSProperties;
213
282
  };
@@ -453,4 +522,4 @@ type FlowRunFeedProps = {
453
522
  /** FlowRunFeed — scrolling log panel. Auto-scrolls to bottom on new entries. */
454
523
  declare function FlowRunFeed({ entries, className, style }: FlowRunFeedProps): react.JSX.Element;
455
524
 
456
- export { ActionNode, ConfigField, ConfigFieldRenderer, type ConfigFieldRendererProps, ConnectionValidatorOptions, DecisionNode, ExecutorRegistry, FlowCanvas, type FlowCanvasProps, FlowEditor, type FlowEditorAction, type FlowEditorApi, type FlowEditorBuiltins, type FlowEditorProps, type FlowEditorSlots, FlowGraph, FlowNode, type FlowNodeRenderProps, FlowRunControls, type FlowRunControlsProps, FlowRunFeed, FlowRunFeedEntry, type FlowRunFeedProps, NodeCategory, NodeConfigPanel, type NodeConfigPanelProps, NodeKindDefinition, NodePalette, type NodePaletteProps, NodePort, type NodePortProps, type NodePortSide, type NodePortType, NodeRunStatus, NodeShell, type NodeShellProps, NoteNode, OutputNode, SubgraphNode, TriggerNode, WorkflowMetadata, WorkflowSchema, defaultNodeTypes, defineNode, paletteDropHandlers, useFlowEditor, useFlowEditorOptional };
525
+ export { ActionNode, type AlignEdge, ConfigField, ConfigFieldRenderer, type ConfigFieldRendererProps, ConnectionValidatorOptions, DecisionNode, ExecutorRegistry, FlowCanvas, type FlowCanvasProps, FlowEditor, type FlowEditorAction, type FlowEditorApi, type FlowEditorBuiltins, type FlowEditorProps, type FlowEditorSlots, FlowGraph, FlowNode, type FlowNodeRenderProps, FlowRunControls, type FlowRunControlsProps, FlowRunFeed, FlowRunFeedEntry, type FlowRunFeedProps, NodeCategory, NodeConfigPanel, type NodeConfigPanelProps, NodeKindDefinition, NodePalette, type NodePaletteProps, NodePort, type NodePortProps, type NodePortSide, type NodePortType, NodeRunStatus, NodeShell, type NodeShellProps, NoteNode, OutputNode, SubgraphNode, TriggerNode, WorkflowMetadata, WorkflowSchema, alignNodes, cloneSubgraph, defaultNodeTypes, defineNode, distributeNodes, paletteDropHandlers, reconnectEdge, useFlowEditor, useFlowEditorOptional };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { useFlowState, useFlowRun, applyStatusesToNodes } from './chunk-MFMRTRPO.js';
2
- export { applyStatusesToNodes, useFlowRun, useFlowState } from './chunk-MFMRTRPO.js';
3
- import { buildNodeTypes, registerBuiltinKinds, createConnectionValidator } from './chunk-FSC64BPD.js';
4
- export { ANY_PORT_TYPE, BUILTIN_KINDS, RegistryNode, buildNodeTypes, createConnectionValidator, defaultPortCompatibility, registerBuiltinKinds } from './chunk-FSC64BPD.js';
5
- import { ReactFlowProvider, useReactFlow, addEdge, applyEdgeChanges, applyNodeChanges, Position, Handle, index, BackgroundVariant, Background, Controls, MiniMap } from './chunk-NF6NPY5N.js';
1
+ import { useFlowState, useFlowRun, useFlowHistory, applyStatusesToNodes } from './chunk-KI4RI2N3.js';
2
+ export { applyStatusesToNodes, createHistory, useFlowHistory, useFlowRun, useFlowState } from './chunk-KI4RI2N3.js';
3
+ import { buildNodeTypes, registerBuiltinKinds, createConnectionValidator } from './chunk-AW2QUEL2.js';
4
+ export { ANY_PORT_TYPE, BUILTIN_KINDS, RegistryNode, buildNodeTypes, createConnectionValidator, defaultPortCompatibility, registerBuiltinKinds } from './chunk-AW2QUEL2.js';
5
+ import { ReactFlowProvider, useReactFlow, addEdge, applyEdgeChanges, applyNodeChanges, Position, Handle, reconnectEdge, index, BackgroundVariant, Background, Controls, MiniMap } from './chunk-XETAGLH6.js';
6
6
  export { LEGACY_PAUSE_PREFIXES, PAUSE_PREFIX, decodePause, encodePause, isPause, pauseForHuman } from './chunk-UEOE6B52.js';
7
7
  export { runFlow } from './chunk-L4AX73Q6.js';
8
8
  import { workflowToBlob, importWorkflow, exportWorkflow } from './chunk-D6W5FMCT.js';
@@ -13,7 +13,7 @@ export { categoryAccent, defaultConfigFor, getNodeKind, listNodeKinds, onNodeKin
13
13
  import { getRichInputAdapter } from './chunk-F5RPRB7A.js';
14
14
  export { RichInputPreview, getRichInputAdapter, isRichInputEnabled, onRichInputAdapterChanged, registerRichInputAdapter } from './chunk-F5RPRB7A.js';
15
15
  import './chunk-USL4FMFU.js';
16
- import { memo, createContext, forwardRef, useState, useEffect, useMemo, useCallback, useImperativeHandle, useRef, useContext } from 'react';
16
+ import { memo, createContext, forwardRef, useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle, useContext } from 'react';
17
17
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
18
18
 
19
19
  function NodeShellInner({
@@ -839,6 +839,65 @@ function duplicateNode(node, id, offset = 40) {
839
839
  data: JSON.parse(JSON.stringify(node.data ?? {}))
840
840
  };
841
841
  }
842
+ function cloneSubgraph(nodes, edges, opts) {
843
+ const offset = opts.offset ?? 40;
844
+ const idMap = /* @__PURE__ */ new Map();
845
+ for (const n of nodes) idMap.set(n.id, opts.makeId());
846
+ const clonedNodes = nodes.map((n) => {
847
+ const cloned = duplicateNode(n, idMap.get(n.id), offset);
848
+ const parentId = n.parentId;
849
+ if (parentId && idMap.has(parentId)) cloned.parentId = idMap.get(parentId);
850
+ else if (parentId) delete cloned.parentId;
851
+ return cloned;
852
+ });
853
+ const clonedEdges = edges.filter((e) => idMap.has(e.source) && idMap.has(e.target)).map((e) => ({ ...e, id: opts.makeId(), source: idMap.get(e.source), target: idMap.get(e.target) }));
854
+ return { nodes: clonedNodes, edges: clonedEdges, idMap };
855
+ }
856
+ function reconnectEdge2(edges, oldEdge, newConnection) {
857
+ return reconnectEdge(oldEdge, newConnection, edges, { shouldReplaceId: false });
858
+ }
859
+ var nodeW = (n) => n.width ?? n.measured?.width ?? 0;
860
+ var nodeH = (n) => n.height ?? n.measured?.height ?? 0;
861
+ function alignNodes(nodes, edge) {
862
+ if (nodes.length < 2) return nodes;
863
+ const minL = Math.min(...nodes.map((n) => n.position.x));
864
+ const maxR = Math.max(...nodes.map((n) => n.position.x + nodeW(n)));
865
+ const minT = Math.min(...nodes.map((n) => n.position.y));
866
+ const maxB = Math.max(...nodes.map((n) => n.position.y + nodeH(n)));
867
+ const cx = (minL + maxR) / 2;
868
+ const cy = (minT + maxB) / 2;
869
+ return nodes.map((n) => {
870
+ let { x, y } = n.position;
871
+ if (edge === "left") x = minL;
872
+ else if (edge === "right") x = maxR - nodeW(n);
873
+ else if (edge === "hcenter") x = cx - nodeW(n) / 2;
874
+ else if (edge === "top") y = minT;
875
+ else if (edge === "bottom") y = maxB - nodeH(n);
876
+ else if (edge === "vcenter") y = cy - nodeH(n) / 2;
877
+ return { ...n, position: { x, y } };
878
+ });
879
+ }
880
+ function distributeNodes(nodes, axis) {
881
+ if (nodes.length < 3) return nodes;
882
+ const size = (n) => axis === "h" ? nodeW(n) : nodeH(n);
883
+ const coord = (n) => axis === "h" ? n.position.x : n.position.y;
884
+ const sorted = [...nodes].sort((a, b) => coord(a) - coord(b));
885
+ const start = coord(sorted[0]);
886
+ const last = sorted[sorted.length - 1];
887
+ const end = coord(last) + size(last);
888
+ const totalSize = sorted.reduce((s, n) => s + size(n), 0);
889
+ const gap = (end - start - totalSize) / (sorted.length - 1);
890
+ const posById = /* @__PURE__ */ new Map();
891
+ let cursor = start;
892
+ for (const n of sorted) {
893
+ posById.set(n.id, cursor);
894
+ cursor += size(n) + gap;
895
+ }
896
+ return nodes.map((n) => {
897
+ const p = posById.get(n.id);
898
+ return { ...n, position: axis === "h" ? { ...n.position, x: p } : { ...n.position, y: p } };
899
+ });
900
+ }
842
901
  var FlowEditorContext = createContext(null);
843
902
  var FlowEditorProvider = FlowEditorContext.Provider;
844
903
  function useFlowEditor() {
@@ -878,13 +937,16 @@ function FlowEditorInner({
878
937
  onSelectionChange,
879
938
  onDelete,
880
939
  onEdgeDelete,
940
+ confirmDelete,
881
941
  apiRef
882
942
  }) {
883
943
  const internal = useFlowState(initial);
884
944
  const runner = useFlowRun();
885
945
  const rf = useReactFlow();
886
946
  const controlled = value !== void 0;
887
- const flow = controlled ? makeControlledFlowAdapter(value, onChange) : internal;
947
+ const baseFlow = controlled ? makeControlledFlowAdapter(value, onChange) : internal;
948
+ const hist = useFlowHistory(baseFlow);
949
+ const flow = hist.flow;
888
950
  const [, force] = useState(0);
889
951
  useEffect(() => onNodeKindsChanged(() => force((n) => n + 1)), []);
890
952
  const nodeTypes = useMemo(() => buildNodeTypes(), [listNodeKinds().length]);
@@ -937,6 +999,23 @@ function FlowEditorInner({
937
999
  window.removeEventListener("keydown", onKey);
938
1000
  };
939
1001
  }, [menu, labelEdit]);
1002
+ useEffect(() => {
1003
+ const onKey = (e) => {
1004
+ const t = e.target;
1005
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1006
+ if (!(e.ctrlKey || e.metaKey)) return;
1007
+ const key = e.key.toLowerCase();
1008
+ if (key === "z" && !e.shiftKey) {
1009
+ e.preventDefault();
1010
+ hist.undo();
1011
+ } else if (key === "z" && e.shiftKey || key === "y") {
1012
+ e.preventDefault();
1013
+ hist.redo();
1014
+ }
1015
+ };
1016
+ window.addEventListener("keydown", onKey);
1017
+ return () => window.removeEventListener("keydown", onKey);
1018
+ }, [hist]);
940
1019
  useEffect(() => {
941
1020
  if (!controlled) onChange?.({ nodes: flow.nodes, edges: flow.edges });
942
1021
  }, [flow.nodes, flow.edges, onChange, controlled]);
@@ -961,16 +1040,116 @@ function FlowEditorInner({
961
1040
  [flow]
962
1041
  );
963
1042
  const deleteNodes = useCallback(
964
- (ids) => {
1043
+ async (ids) => {
965
1044
  if (ids.length === 0) return;
1045
+ const targets = flow.nodes.filter((n) => ids.includes(n.id));
1046
+ if (targets.length === 0) return;
1047
+ if (confirmDelete) {
1048
+ const attached = flow.edges.filter((e) => ids.includes(e.source) || ids.includes(e.target));
1049
+ if (!await confirmDelete({ nodes: targets, edges: attached })) return;
1050
+ }
966
1051
  const doomed = new Set(ids);
967
- flow.setNodes((all) => removeNodes({ nodes: all, edges: [] }, ids).nodes);
968
- flow.setEdges((all) => removeNodes({ nodes: [], edges: all }, ids).edges);
1052
+ flow.setGraph(removeNodes({ nodes: flow.nodes, edges: flow.edges }, ids));
969
1053
  setSelectedId((cur) => cur !== null && doomed.has(cur) ? null : cur);
970
1054
  onDelete?.(ids);
971
1055
  },
972
- [flow, onDelete]
1056
+ [flow, onDelete, confirmDelete]
1057
+ );
1058
+ const deleteEdges = useCallback(
1059
+ async (ids) => {
1060
+ if (ids.length === 0) return;
1061
+ const targets = flow.edges.filter((e) => ids.includes(e.id));
1062
+ if (targets.length === 0) return;
1063
+ if (confirmDelete && !await confirmDelete({ nodes: [], edges: targets })) return;
1064
+ flow.setEdges((all) => removeEdges(all, ids));
1065
+ setSelectedEdgeId((cur) => cur !== null && ids.includes(cur) ? null : cur);
1066
+ onEdgeDelete?.(ids);
1067
+ },
1068
+ [flow, onEdgeDelete, confirmDelete]
1069
+ );
1070
+ const selectedNodes = useMemo(() => flow.nodes.filter((n) => n.selected), [flow.nodes]);
1071
+ const selectedIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
1072
+ const clipboard = useRef(null);
1073
+ const copySelection = useCallback(() => {
1074
+ const sel = flow.nodes.filter((n) => n.selected);
1075
+ if (sel.length === 0) return;
1076
+ const ids = new Set(sel.map((n) => n.id));
1077
+ const edges = flow.edges.filter((e) => ids.has(e.source) && ids.has(e.target));
1078
+ clipboard.current = { nodes: sel.map((n) => ({ ...n })), edges: edges.map((e) => ({ ...e })) };
1079
+ }, [flow]);
1080
+ const pasteClipboard = useCallback(
1081
+ (at) => {
1082
+ const clip = clipboard.current;
1083
+ if (!clip || clip.nodes.length === 0) return;
1084
+ const { nodes: clones, edges: cloneEdges } = cloneSubgraph(clip.nodes, clip.edges, { makeId: newNodeId, offset: 40 });
1085
+ let placed = clones;
1086
+ if (at) {
1087
+ const minX = Math.min(...clones.map((n) => n.position.x));
1088
+ const minY = Math.min(...clones.map((n) => n.position.y));
1089
+ placed = clones.map((n) => ({ ...n, position: { x: n.position.x - minX + at.x, y: n.position.y - minY + at.y } }));
1090
+ }
1091
+ flow.setGraph({
1092
+ nodes: [...flow.nodes.map((n) => ({ ...n, selected: false })), ...placed.map((n) => ({ ...n, selected: true }))],
1093
+ edges: [...flow.edges, ...cloneEdges]
1094
+ });
1095
+ },
1096
+ [flow]
1097
+ );
1098
+ const duplicateSelected = useCallback(() => {
1099
+ const sel = flow.nodes.filter((n) => n.selected);
1100
+ if (sel.length === 0) return;
1101
+ const ids = new Set(sel.map((n) => n.id));
1102
+ const internal2 = flow.edges.filter((e) => ids.has(e.source) && ids.has(e.target));
1103
+ const { nodes: clones, edges: cloneEdges } = cloneSubgraph(sel, internal2, { makeId: newNodeId, offset: 40 });
1104
+ flow.setGraph({
1105
+ nodes: [...flow.nodes.map((n) => ({ ...n, selected: false })), ...clones.map((n) => ({ ...n, selected: true }))],
1106
+ edges: [...flow.edges, ...cloneEdges]
1107
+ });
1108
+ }, [flow]);
1109
+ const alignSelected = useCallback(
1110
+ (edge) => {
1111
+ const sel = flow.nodes.filter((n) => n.selected);
1112
+ if (sel.length < 2) return;
1113
+ const byId = new Map(alignNodes(sel, edge).map((n) => [n.id, n]));
1114
+ flow.setNodes((all) => all.map((n) => byId.get(n.id) ?? n));
1115
+ },
1116
+ [flow]
973
1117
  );
1118
+ const distributeSelected = useCallback(
1119
+ (axis) => {
1120
+ const sel = flow.nodes.filter((n) => n.selected);
1121
+ if (sel.length < 3) return;
1122
+ const byId = new Map(distributeNodes(sel, axis).map((n) => [n.id, n]));
1123
+ flow.setNodes((all) => all.map((n) => byId.get(n.id) ?? n));
1124
+ },
1125
+ [flow]
1126
+ );
1127
+ const onReconnect = useCallback(
1128
+ (oldEdge, conn) => flow.setEdges((eds) => reconnectEdge2(eds, oldEdge, conn)),
1129
+ [flow]
1130
+ );
1131
+ useEffect(() => {
1132
+ const onKey = (e) => {
1133
+ const t = e.target;
1134
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1135
+ if (!(e.ctrlKey || e.metaKey)) return;
1136
+ const key = e.key.toLowerCase();
1137
+ if (key === "c") copySelection();
1138
+ else if (key === "x") {
1139
+ e.preventDefault();
1140
+ copySelection();
1141
+ deleteNodes(selectedIds);
1142
+ } else if (key === "v") {
1143
+ e.preventDefault();
1144
+ pasteClipboard();
1145
+ } else if (key === "d") {
1146
+ e.preventDefault();
1147
+ duplicateSelected();
1148
+ }
1149
+ };
1150
+ window.addEventListener("keydown", onKey);
1151
+ return () => window.removeEventListener("keydown", onKey);
1152
+ }, [copySelection, pasteClipboard, duplicateSelected, deleteNodes, selectedIds]);
974
1153
  const api = useMemo(() => {
975
1154
  const toWorkflow = () => exportWorkflow({ nodes: flow.nodes, edges: flow.edges }, metadata);
976
1155
  return {
@@ -981,6 +1160,8 @@ function FlowEditorInner({
981
1160
  selected,
982
1161
  selectedEdgeId,
983
1162
  selectedEdge,
1163
+ selectedIds,
1164
+ selectedNodes,
984
1165
  running: runner.running,
985
1166
  statuses: runner.statuses,
986
1167
  select: setSelectedId,
@@ -989,18 +1170,8 @@ function FlowEditorInner({
989
1170
  updateNode: (next) => flow.setNodes((all) => all.map((x) => x.id === next.id ? next : x)),
990
1171
  deleteNodes,
991
1172
  deleteSelected: () => deleteNodes(selectedId ? [selectedId] : []),
992
- deleteEdges: (ids) => {
993
- if (ids.length === 0) return;
994
- flow.setEdges((all) => removeEdges(all, ids));
995
- setSelectedEdgeId((cur) => cur !== null && ids.includes(cur) ? null : cur);
996
- onEdgeDelete?.(ids);
997
- },
998
- deleteSelectedEdge: () => {
999
- if (!selectedEdgeId) return;
1000
- flow.setEdges((all) => removeEdges(all, [selectedEdgeId]));
1001
- setSelectedEdgeId(null);
1002
- onEdgeDelete?.([selectedEdgeId]);
1003
- },
1173
+ deleteEdges,
1174
+ deleteSelectedEdge: () => deleteEdges(selectedEdgeId ? [selectedEdgeId] : []),
1004
1175
  setEdgeLabel: (id, label) => flow.setEdges((all) => setEdgeLabel(all, id, label)),
1005
1176
  duplicateNode: (id) => {
1006
1177
  const src = flow.nodes.find((n) => n.id === id);
@@ -1010,22 +1181,29 @@ function FlowEditorInner({
1010
1181
  setSelectedId(copy.id);
1011
1182
  return copy.id;
1012
1183
  },
1013
- setGraph: (graph) => {
1014
- flow.setNodes(graph.nodes);
1015
- flow.setEdges(graph.edges);
1184
+ setGraph: (graph) => flow.setGraph(graph),
1185
+ duplicateSelected,
1186
+ alignSelected,
1187
+ distributeSelected,
1188
+ copy: copySelection,
1189
+ cut: () => {
1190
+ copySelection();
1191
+ deleteNodes(selectedIds);
1016
1192
  },
1193
+ paste: pasteClipboard,
1017
1194
  run: () => runner.run({ nodes: flow.nodes, edges: flow.edges }, executors),
1018
1195
  cancel: runner.cancel,
1019
1196
  reset: runner.reset,
1020
1197
  toWorkflow,
1021
1198
  exportWorkflow: () => downloadWorkflow(toWorkflow(), metadata),
1022
- importWorkflow: () => pickWorkflow((graph) => {
1023
- flow.setNodes(graph.nodes);
1024
- flow.setEdges(graph.edges);
1025
- }),
1026
- fitView: () => rf.fitView({ padding: 0.2 })
1199
+ importWorkflow: () => pickWorkflow((graph) => flow.setGraph(graph)),
1200
+ fitView: () => rf.fitView({ padding: 0.2 }),
1201
+ undo: hist.undo,
1202
+ redo: hist.redo,
1203
+ canUndo: hist.canUndo,
1204
+ canRedo: hist.canRedo
1027
1205
  };
1028
- }, [flow, selectedId, selected, runner, executors, metadata, addNode, deleteNodes, rf]);
1206
+ }, [flow, selectedId, selected, selectedEdgeId, selectedEdge, selectedIds, selectedNodes, runner, executors, metadata, addNode, deleteNodes, deleteEdges, duplicateSelected, alignSelected, distributeSelected, copySelection, pasteClipboard, hist, rf]);
1029
1207
  useImperativeHandle(apiRef, () => api, [api]);
1030
1208
  const dropHandlers = paletteDropHandlers((kindName, evt) => {
1031
1209
  const point = rf.screenToFlowPosition({ x: evt.clientX, y: evt.clientY });
@@ -1036,6 +1214,31 @@ function FlowEditorInner({
1036
1214
  const toolbar = slots.toolbar ? slots.toolbar(api) : /* @__PURE__ */ jsxs(Fragment, { children: [
1037
1215
  startActions.map((a) => renderAction(a, api)),
1038
1216
  builtins.run !== false && /* @__PURE__ */ jsx(FlowRunControls, { running: api.running, onRun: api.run, onCancel: api.cancel, onReset: api.reset }),
1217
+ builtins.history !== false && /* @__PURE__ */ jsxs(Fragment, { children: [
1218
+ /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
1219
+ /* @__PURE__ */ jsx(
1220
+ "button",
1221
+ {
1222
+ className: "ff-editor__btn",
1223
+ "data-action": "undo",
1224
+ title: "Undo (Ctrl+Z)",
1225
+ disabled: !api.canUndo,
1226
+ onClick: api.undo,
1227
+ children: "\u21B6 Undo"
1228
+ }
1229
+ ),
1230
+ /* @__PURE__ */ jsx(
1231
+ "button",
1232
+ {
1233
+ className: "ff-editor__btn",
1234
+ "data-action": "redo",
1235
+ title: "Redo (Ctrl+Shift+Z)",
1236
+ disabled: !api.canRedo,
1237
+ onClick: api.redo,
1238
+ children: "\u21B7 Redo"
1239
+ }
1240
+ )
1241
+ ] }),
1039
1242
  (builtins.export !== false || builtins.import !== false) && /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
1040
1243
  builtins.export !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "export", onClick: api.exportWorkflow, children: "\u2193 Export" }),
1041
1244
  builtins.import !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "import", onClick: api.importWorkflow, children: "\u2191 Import" }),
@@ -1060,12 +1263,16 @@ function FlowEditorInner({
1060
1263
  onNodesChange: flow.onNodesChange,
1061
1264
  onEdgesChange: flow.onEdgesChange,
1062
1265
  onConnect: flow.onConnect,
1266
+ onReconnect,
1267
+ edgesReconnectable: true,
1268
+ onNodeDragStart: hist.onNodeDragStart,
1063
1269
  onNodeClick: handleNodeClick,
1064
1270
  onNodeContextMenu: builtins.contextMenu === false ? void 0 : handleNodeContextMenu,
1065
1271
  onEdgeClick: handleEdgeClick,
1066
1272
  onEdgeContextMenu: builtins.edgeContextMenu === false ? void 0 : handleEdgeContextMenu,
1067
1273
  onEdgesDelete: (deleted) => onEdgeDelete?.(deleted.map((e) => e.id)),
1068
1274
  onNodesDelete: (deleted) => onDelete?.(deleted.map((n) => n.id)),
1275
+ onBeforeDelete: confirmDelete ? async ({ nodes, edges }) => confirmDelete({ nodes, edges }) : void 0,
1069
1276
  deleteKeyCode: ["Delete", "Backspace"],
1070
1277
  height: "100%",
1071
1278
  toolbar,
@@ -1236,6 +1443,8 @@ function makeControlledFlowAdapter(value, onChange) {
1236
1443
  const nextEdges = typeof next === "function" ? next(value.edges) : next;
1237
1444
  apply({ nodes: value.nodes, edges: nextEdges });
1238
1445
  },
1446
+ // Atomic both-at-once commit — the reason this exists (see UseFlowStateReturn).
1447
+ setGraph: (graph) => apply(graph),
1239
1448
  onNodesChange: (changes) => {
1240
1449
  apply({ nodes: applyNodeChanges(changes, value.nodes), edges: value.edges });
1241
1450
  },
@@ -1322,6 +1531,6 @@ function NodePort({ side, type, id, style, title, className }) {
1322
1531
  // src/index.ts
1323
1532
  registerBuiltinKinds();
1324
1533
 
1325
- export { ActionNode, ConfigFieldRenderer, DecisionNode, FlowCanvas, FlowEditor, FlowRunControls, FlowRunFeed, NodeConfigPanel, NodePalette, NodePort, NodeShell, NoteNode, OutputNode, SubgraphNode, TriggerNode, defaultNodeTypes, defineNode, paletteDropHandlers, useFlowEditor, useFlowEditorOptional };
1534
+ export { ActionNode, ConfigFieldRenderer, DecisionNode, FlowCanvas, FlowEditor, FlowRunControls, FlowRunFeed, NodeConfigPanel, NodePalette, NodePort, NodeShell, NoteNode, OutputNode, SubgraphNode, TriggerNode, alignNodes, cloneSubgraph, defaultNodeTypes, defineNode, distributeNodes, paletteDropHandlers, reconnectEdge2 as reconnectEdge, useFlowEditor, useFlowEditorOptional };
1326
1535
  //# sourceMappingURL=index.js.map
1327
1536
  //# sourceMappingURL=index.js.map