@particle-academy/fancy-flow 0.18.0 → 0.19.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
@@ -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';
@@ -109,6 +109,16 @@ type FlowEditorApi = {
109
109
  /** Open a file picker and load a workflow. */
110
110
  importWorkflow: () => void;
111
111
  fitView: () => void;
112
+ /** Undo the last committing edit (add / delete / connect / config / drag /
113
+ * import). Transient interactions (a drag in progress, selection) are not
114
+ * their own steps. */
115
+ undo: () => void;
116
+ /** Redo the last undone edit. */
117
+ redo: () => void;
118
+ /** True when there is an edit to undo. */
119
+ canUndo: boolean;
120
+ /** True when there is an undone edit to redo. */
121
+ canRedo: boolean;
112
122
  };
113
123
  /**
114
124
  * A declarative toolbar button. JSON-friendly on purpose — a host (or an
@@ -134,6 +144,8 @@ type FlowEditorAction = {
134
144
  type FlowEditorBuiltins = {
135
145
  run?: boolean;
136
146
  delete?: boolean;
147
+ /** Undo/redo toolbar buttons. Default true. */
148
+ history?: boolean;
137
149
  /** Right-click a node for Delete / Duplicate. Default true. */
138
150
  contextMenu?: boolean;
139
151
  /** Right-click a connection for Label / Delete. Default true. */
@@ -208,6 +220,17 @@ type FlowEditorProps = {
208
220
  onDelete?: (ids: string[]) => void;
209
221
  /** Called after connections are broken, with the deleted edge ids. */
210
222
  onEdgeDelete?: (ids: string[]) => void;
223
+ /**
224
+ * Stage destructive edits for human confirmation. When set, every delete path
225
+ * — keyboard, panel, context menu, and `api.deleteNodes`/`deleteEdges` —
226
+ * calls this first; return false to veto. Default: delete immediately. This
227
+ * realizes the component contract's "agents propose, humans confirm" on the
228
+ * canvas.
229
+ */
230
+ confirmDelete?: (targets: {
231
+ nodes: FlowNode[];
232
+ edges: Edge[];
233
+ }) => boolean | Promise<boolean>;
211
234
  className?: string;
212
235
  style?: CSSProperties;
213
236
  };
package/dist/index.d.ts CHANGED
@@ -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';
@@ -109,6 +109,16 @@ type FlowEditorApi = {
109
109
  /** Open a file picker and load a workflow. */
110
110
  importWorkflow: () => void;
111
111
  fitView: () => void;
112
+ /** Undo the last committing edit (add / delete / connect / config / drag /
113
+ * import). Transient interactions (a drag in progress, selection) are not
114
+ * their own steps. */
115
+ undo: () => void;
116
+ /** Redo the last undone edit. */
117
+ redo: () => void;
118
+ /** True when there is an edit to undo. */
119
+ canUndo: boolean;
120
+ /** True when there is an undone edit to redo. */
121
+ canRedo: boolean;
112
122
  };
113
123
  /**
114
124
  * A declarative toolbar button. JSON-friendly on purpose — a host (or an
@@ -134,6 +144,8 @@ type FlowEditorAction = {
134
144
  type FlowEditorBuiltins = {
135
145
  run?: boolean;
136
146
  delete?: boolean;
147
+ /** Undo/redo toolbar buttons. Default true. */
148
+ history?: boolean;
137
149
  /** Right-click a node for Delete / Duplicate. Default true. */
138
150
  contextMenu?: boolean;
139
151
  /** Right-click a connection for Label / Delete. Default true. */
@@ -208,6 +220,17 @@ type FlowEditorProps = {
208
220
  onDelete?: (ids: string[]) => void;
209
221
  /** Called after connections are broken, with the deleted edge ids. */
210
222
  onEdgeDelete?: (ids: string[]) => void;
223
+ /**
224
+ * Stage destructive edits for human confirmation. When set, every delete path
225
+ * — keyboard, panel, context menu, and `api.deleteNodes`/`deleteEdges` —
226
+ * calls this first; return false to veto. Default: delete immediately. This
227
+ * realizes the component contract's "agents propose, humans confirm" on the
228
+ * canvas.
229
+ */
230
+ confirmDelete?: (targets: {
231
+ nodes: FlowNode[];
232
+ edges: Edge[];
233
+ }) => boolean | Promise<boolean>;
211
234
  className?: string;
212
235
  style?: CSSProperties;
213
236
  };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { useFlowState, useFlowRun, applyStatusesToNodes } from './chunk-MFMRTRPO.js';
2
- export { applyStatusesToNodes, useFlowRun, useFlowState } from './chunk-MFMRTRPO.js';
1
+ import { useFlowState, useFlowRun, useFlowHistory, applyStatusesToNodes } from './chunk-PHX4SBOD.js';
2
+ export { applyStatusesToNodes, createHistory, useFlowHistory, useFlowRun, useFlowState } from './chunk-PHX4SBOD.js';
3
3
  import { buildNodeTypes, registerBuiltinKinds, createConnectionValidator } from './chunk-FSC64BPD.js';
4
4
  export { ANY_PORT_TYPE, BUILTIN_KINDS, RegistryNode, buildNodeTypes, createConnectionValidator, defaultPortCompatibility, registerBuiltinKinds } from './chunk-FSC64BPD.js';
5
5
  import { ReactFlowProvider, useReactFlow, addEdge, applyEdgeChanges, applyNodeChanges, Position, Handle, index, BackgroundVariant, Background, Controls, MiniMap } from './chunk-NF6NPY5N.js';
@@ -878,13 +878,16 @@ function FlowEditorInner({
878
878
  onSelectionChange,
879
879
  onDelete,
880
880
  onEdgeDelete,
881
+ confirmDelete,
881
882
  apiRef
882
883
  }) {
883
884
  const internal = useFlowState(initial);
884
885
  const runner = useFlowRun();
885
886
  const rf = useReactFlow();
886
887
  const controlled = value !== void 0;
887
- const flow = controlled ? makeControlledFlowAdapter(value, onChange) : internal;
888
+ const baseFlow = controlled ? makeControlledFlowAdapter(value, onChange) : internal;
889
+ const hist = useFlowHistory(baseFlow);
890
+ const flow = hist.flow;
888
891
  const [, force] = useState(0);
889
892
  useEffect(() => onNodeKindsChanged(() => force((n) => n + 1)), []);
890
893
  const nodeTypes = useMemo(() => buildNodeTypes(), [listNodeKinds().length]);
@@ -937,6 +940,23 @@ function FlowEditorInner({
937
940
  window.removeEventListener("keydown", onKey);
938
941
  };
939
942
  }, [menu, labelEdit]);
943
+ useEffect(() => {
944
+ const onKey = (e) => {
945
+ const t = e.target;
946
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
947
+ if (!(e.ctrlKey || e.metaKey)) return;
948
+ const key = e.key.toLowerCase();
949
+ if (key === "z" && !e.shiftKey) {
950
+ e.preventDefault();
951
+ hist.undo();
952
+ } else if (key === "z" && e.shiftKey || key === "y") {
953
+ e.preventDefault();
954
+ hist.redo();
955
+ }
956
+ };
957
+ window.addEventListener("keydown", onKey);
958
+ return () => window.removeEventListener("keydown", onKey);
959
+ }, [hist]);
940
960
  useEffect(() => {
941
961
  if (!controlled) onChange?.({ nodes: flow.nodes, edges: flow.edges });
942
962
  }, [flow.nodes, flow.edges, onChange, controlled]);
@@ -961,15 +981,32 @@ function FlowEditorInner({
961
981
  [flow]
962
982
  );
963
983
  const deleteNodes = useCallback(
964
- (ids) => {
984
+ async (ids) => {
965
985
  if (ids.length === 0) return;
986
+ const targets = flow.nodes.filter((n) => ids.includes(n.id));
987
+ if (targets.length === 0) return;
988
+ if (confirmDelete) {
989
+ const attached = flow.edges.filter((e) => ids.includes(e.source) || ids.includes(e.target));
990
+ if (!await confirmDelete({ nodes: targets, edges: attached })) return;
991
+ }
966
992
  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);
993
+ flow.setGraph(removeNodes({ nodes: flow.nodes, edges: flow.edges }, ids));
969
994
  setSelectedId((cur) => cur !== null && doomed.has(cur) ? null : cur);
970
995
  onDelete?.(ids);
971
996
  },
972
- [flow, onDelete]
997
+ [flow, onDelete, confirmDelete]
998
+ );
999
+ const deleteEdges = useCallback(
1000
+ async (ids) => {
1001
+ if (ids.length === 0) return;
1002
+ const targets = flow.edges.filter((e) => ids.includes(e.id));
1003
+ if (targets.length === 0) return;
1004
+ if (confirmDelete && !await confirmDelete({ nodes: [], edges: targets })) return;
1005
+ flow.setEdges((all) => removeEdges(all, ids));
1006
+ setSelectedEdgeId((cur) => cur !== null && ids.includes(cur) ? null : cur);
1007
+ onEdgeDelete?.(ids);
1008
+ },
1009
+ [flow, onEdgeDelete, confirmDelete]
973
1010
  );
974
1011
  const api = useMemo(() => {
975
1012
  const toWorkflow = () => exportWorkflow({ nodes: flow.nodes, edges: flow.edges }, metadata);
@@ -989,18 +1026,8 @@ function FlowEditorInner({
989
1026
  updateNode: (next) => flow.setNodes((all) => all.map((x) => x.id === next.id ? next : x)),
990
1027
  deleteNodes,
991
1028
  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
- },
1029
+ deleteEdges,
1030
+ deleteSelectedEdge: () => deleteEdges(selectedEdgeId ? [selectedEdgeId] : []),
1004
1031
  setEdgeLabel: (id, label) => flow.setEdges((all) => setEdgeLabel(all, id, label)),
1005
1032
  duplicateNode: (id) => {
1006
1033
  const src = flow.nodes.find((n) => n.id === id);
@@ -1010,22 +1037,20 @@ function FlowEditorInner({
1010
1037
  setSelectedId(copy.id);
1011
1038
  return copy.id;
1012
1039
  },
1013
- setGraph: (graph) => {
1014
- flow.setNodes(graph.nodes);
1015
- flow.setEdges(graph.edges);
1016
- },
1040
+ setGraph: (graph) => flow.setGraph(graph),
1017
1041
  run: () => runner.run({ nodes: flow.nodes, edges: flow.edges }, executors),
1018
1042
  cancel: runner.cancel,
1019
1043
  reset: runner.reset,
1020
1044
  toWorkflow,
1021
1045
  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 })
1046
+ importWorkflow: () => pickWorkflow((graph) => flow.setGraph(graph)),
1047
+ fitView: () => rf.fitView({ padding: 0.2 }),
1048
+ undo: hist.undo,
1049
+ redo: hist.redo,
1050
+ canUndo: hist.canUndo,
1051
+ canRedo: hist.canRedo
1027
1052
  };
1028
- }, [flow, selectedId, selected, runner, executors, metadata, addNode, deleteNodes, rf]);
1053
+ }, [flow, selectedId, selected, selectedEdgeId, selectedEdge, runner, executors, metadata, addNode, deleteNodes, deleteEdges, hist, rf]);
1029
1054
  useImperativeHandle(apiRef, () => api, [api]);
1030
1055
  const dropHandlers = paletteDropHandlers((kindName, evt) => {
1031
1056
  const point = rf.screenToFlowPosition({ x: evt.clientX, y: evt.clientY });
@@ -1036,6 +1061,31 @@ function FlowEditorInner({
1036
1061
  const toolbar = slots.toolbar ? slots.toolbar(api) : /* @__PURE__ */ jsxs(Fragment, { children: [
1037
1062
  startActions.map((a) => renderAction(a, api)),
1038
1063
  builtins.run !== false && /* @__PURE__ */ jsx(FlowRunControls, { running: api.running, onRun: api.run, onCancel: api.cancel, onReset: api.reset }),
1064
+ builtins.history !== false && /* @__PURE__ */ jsxs(Fragment, { children: [
1065
+ /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
1066
+ /* @__PURE__ */ jsx(
1067
+ "button",
1068
+ {
1069
+ className: "ff-editor__btn",
1070
+ "data-action": "undo",
1071
+ title: "Undo (Ctrl+Z)",
1072
+ disabled: !api.canUndo,
1073
+ onClick: api.undo,
1074
+ children: "\u21B6 Undo"
1075
+ }
1076
+ ),
1077
+ /* @__PURE__ */ jsx(
1078
+ "button",
1079
+ {
1080
+ className: "ff-editor__btn",
1081
+ "data-action": "redo",
1082
+ title: "Redo (Ctrl+Shift+Z)",
1083
+ disabled: !api.canRedo,
1084
+ onClick: api.redo,
1085
+ children: "\u21B7 Redo"
1086
+ }
1087
+ )
1088
+ ] }),
1039
1089
  (builtins.export !== false || builtins.import !== false) && /* @__PURE__ */ jsx("span", { className: "ff-editor__sep" }),
1040
1090
  builtins.export !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "export", onClick: api.exportWorkflow, children: "\u2193 Export" }),
1041
1091
  builtins.import !== false && /* @__PURE__ */ jsx("button", { className: "ff-editor__btn", "data-action": "import", onClick: api.importWorkflow, children: "\u2191 Import" }),
@@ -1060,12 +1110,14 @@ function FlowEditorInner({
1060
1110
  onNodesChange: flow.onNodesChange,
1061
1111
  onEdgesChange: flow.onEdgesChange,
1062
1112
  onConnect: flow.onConnect,
1113
+ onNodeDragStart: hist.onNodeDragStart,
1063
1114
  onNodeClick: handleNodeClick,
1064
1115
  onNodeContextMenu: builtins.contextMenu === false ? void 0 : handleNodeContextMenu,
1065
1116
  onEdgeClick: handleEdgeClick,
1066
1117
  onEdgeContextMenu: builtins.edgeContextMenu === false ? void 0 : handleEdgeContextMenu,
1067
1118
  onEdgesDelete: (deleted) => onEdgeDelete?.(deleted.map((e) => e.id)),
1068
1119
  onNodesDelete: (deleted) => onDelete?.(deleted.map((n) => n.id)),
1120
+ onBeforeDelete: confirmDelete ? async ({ nodes, edges }) => confirmDelete({ nodes, edges }) : void 0,
1069
1121
  deleteKeyCode: ["Delete", "Backspace"],
1070
1122
  height: "100%",
1071
1123
  toolbar,
@@ -1236,6 +1288,8 @@ function makeControlledFlowAdapter(value, onChange) {
1236
1288
  const nextEdges = typeof next === "function" ? next(value.edges) : next;
1237
1289
  apply({ nodes: value.nodes, edges: nextEdges });
1238
1290
  },
1291
+ // Atomic both-at-once commit — the reason this exists (see UseFlowStateReturn).
1292
+ setGraph: (graph) => apply(graph),
1239
1293
  onNodesChange: (changes) => {
1240
1294
  apply({ nodes: applyNodeChanges(changes, value.nodes), edges: value.edges });
1241
1295
  },