@bian-womp/spark-workbench 0.2.68 → 0.2.70

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.
Files changed (55) hide show
  1. package/lib/cjs/index.cjs +390 -88
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +27 -7
  4. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/contracts.d.ts +5 -0
  6. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/DefaultContextMenu.d.ts +1 -1
  8. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/NodeContextMenu.d.ts +1 -1
  10. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/SelectionContextMenu.d.ts +1 -1
  12. package/lib/cjs/src/misc/SelectionContextMenu.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +22 -0
  15. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  16. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts +1 -1
  17. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +11 -22
  19. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  20. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  21. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  22. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +6 -1
  23. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  24. package/lib/cjs/src/runtime/IGraphRunner.d.ts +6 -1
  25. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  26. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +6 -1
  27. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  28. package/lib/esm/index.js +390 -88
  29. package/lib/esm/index.js.map +1 -1
  30. package/lib/esm/src/core/InMemoryWorkbench.d.ts +27 -7
  31. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  32. package/lib/esm/src/core/contracts.d.ts +5 -0
  33. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  34. package/lib/esm/src/misc/DefaultContextMenu.d.ts +1 -1
  35. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  36. package/lib/esm/src/misc/NodeContextMenu.d.ts +1 -1
  37. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  38. package/lib/esm/src/misc/SelectionContextMenu.d.ts +1 -1
  39. package/lib/esm/src/misc/SelectionContextMenu.d.ts.map +1 -1
  40. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  41. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +22 -0
  42. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  43. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts +1 -1
  44. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  45. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +11 -22
  46. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  47. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  48. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  49. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +6 -1
  50. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  51. package/lib/esm/src/runtime/IGraphRunner.d.ts +6 -1
  52. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  53. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +6 -1
  54. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  55. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var sparkGraph = require('@bian-womp/spark-graph');
4
+ var lod = require('lodash');
4
5
  var sparkRemote = require('@bian-womp/spark-remote');
5
6
  var react = require('@xyflow/react');
6
7
  var React = require('react');
@@ -210,18 +211,19 @@ class InMemoryWorkbench extends AbstractWorkbench {
210
211
  inputs: options?.inputs,
211
212
  copyOutputsFrom: options?.copyOutputsFrom,
212
213
  },
213
- dry: options?.dry,
214
+ ...lod.pick(options, ["dry", "commit", "reason"]),
214
215
  });
215
216
  this.refreshValidation();
216
217
  return id;
217
218
  }
218
- removeNode(nodeId) {
219
+ removeNode(nodeId, options) {
219
220
  this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
220
221
  this.def.edges = this.def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
221
222
  delete this.positions[nodeId];
222
223
  this.emit("graphChanged", {
223
224
  def: this.def,
224
225
  change: { type: "removeNode", nodeId },
226
+ ...options,
225
227
  });
226
228
  this.refreshValidation();
227
229
  }
@@ -236,16 +238,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
236
238
  this.emit("graphChanged", {
237
239
  def: this.def,
238
240
  change: { type: "connect", edgeId: id },
239
- dry: options?.dry,
241
+ ...options,
240
242
  });
241
243
  this.refreshValidation();
242
244
  return id;
243
245
  }
244
- disconnect(edgeId) {
246
+ disconnect(edgeId, options) {
245
247
  this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
246
248
  this.emit("graphChanged", {
247
249
  def: this.def,
248
250
  change: { type: "disconnect", edgeId },
251
+ ...options,
249
252
  });
250
253
  this.emit("validationChanged", this.validate());
251
254
  }
@@ -274,32 +277,32 @@ class InMemoryWorkbench extends AbstractWorkbench {
274
277
  });
275
278
  }
276
279
  // Position and selection APIs for React Flow bridge
277
- setPosition(nodeId, pos, opts) {
280
+ setPosition(nodeId, pos, options) {
278
281
  this.positions[nodeId] = pos;
279
282
  this.emit("graphUiChanged", {
280
283
  def: this.def,
281
284
  change: { type: "moveNode", nodeId, pos },
282
- commit: !!opts?.commit === true,
285
+ ...options,
283
286
  });
284
287
  }
285
- setPositions(map, opts) {
288
+ setPositions(map, options) {
286
289
  this.positions = { ...map };
287
290
  this.emit("graphUiChanged", {
288
291
  def: this.def,
289
292
  change: { type: "moveNodes" },
290
- commit: opts?.commit,
293
+ ...options,
291
294
  });
292
295
  }
293
296
  getPositions() {
294
297
  return { ...this.positions };
295
298
  }
296
- setSelection(sel, opts) {
299
+ setSelection(sel, options) {
297
300
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
298
301
  this.emit("selectionChanged", this.selection);
299
302
  this.emit("graphUiChanged", {
300
303
  def: this.def,
301
304
  change: { type: "selection" },
302
- commit: opts?.commit,
305
+ ...options,
303
306
  });
304
307
  }
305
308
  getSelection() {
@@ -311,7 +314,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
311
314
  /**
312
315
  * Delete all selected nodes and edges.
313
316
  */
314
- deleteSelection() {
317
+ deleteSelection(options) {
315
318
  const selection = this.getSelection();
316
319
  // Delete all selected nodes (this will also remove connected edges)
317
320
  for (const nodeId of selection.nodes) {
@@ -322,14 +325,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
322
325
  this.disconnect(edgeId);
323
326
  }
324
327
  // Clear selection
325
- this.setSelection({ nodes: [], edges: [] });
328
+ this.setSelection({ nodes: [], edges: [] }, options);
326
329
  }
327
- setViewport(viewport, opts) {
330
+ setViewport(viewport, options) {
328
331
  this.viewport = { ...viewport };
329
332
  this.emit("graphUiChanged", {
330
333
  def: this.def,
331
334
  change: { type: "viewport" },
332
- commit: opts?.commit,
335
+ ...options,
333
336
  });
334
337
  }
335
338
  getViewport() {
@@ -474,7 +477,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
474
477
  * Returns the mapping from original node IDs to new node IDs.
475
478
  * Uses copyOutputsFrom to copy outputs from original nodes (like duplicate does).
476
479
  */
477
- pasteCopiedData(data, center) {
480
+ pasteCopiedData(data, center, options) {
478
481
  const nodeIdMap = new Map();
479
482
  const edgeIds = [];
480
483
  // Add nodes
@@ -514,10 +517,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
514
517
  }
515
518
  }
516
519
  // Select the newly pasted nodes
517
- this.setSelection({
518
- nodes: Array.from(nodeIdMap.values()),
519
- edges: edgeIds,
520
- });
520
+ this.setSelection({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
521
521
  return { nodeIdMap, edgeIds };
522
522
  }
523
523
  /**
@@ -627,11 +627,10 @@ class AbstractGraphRunner {
627
627
  this.runtime.resume();
628
628
  // Create and launch new engine (to be implemented by subclasses)
629
629
  await this.createAndLaunchEngine(opts);
630
- // Re-apply staged inputs to new engine
630
+ // Re-apply staged inputs to new engine using runner's setInputs method
631
+ // This ensures consistency and proper handling of staged inputs
631
632
  for (const [nodeId, map] of Object.entries(currentInputs)) {
632
- if (this.engine) {
633
- this.engine.setInputs(nodeId, map);
634
- }
633
+ await this.setInputs(nodeId, map);
635
634
  }
636
635
  }
637
636
  getInputDefaults(def) {
@@ -673,6 +672,21 @@ class AbstractGraphRunner {
673
672
  getRunningEngine() {
674
673
  return this.runningKind;
675
674
  }
675
+ // Optional undo/redo support
676
+ async undo() {
677
+ return false;
678
+ }
679
+ async redo() {
680
+ return false;
681
+ }
682
+ async canUndo() {
683
+ return false;
684
+ }
685
+ async canRedo() {
686
+ return false;
687
+ }
688
+ // Optional commit support
689
+ async commit(_reason) { }
676
690
  }
677
691
 
678
692
  // Counter for generating readable runner IDs
@@ -1297,8 +1311,11 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1297
1311
  this.engine = eng;
1298
1312
  this.runningKind = opts?.engine ?? "push";
1299
1313
  this.emit("status", { running: true, engine: this.runningKind });
1314
+ // Re-apply staged inputs using client.setInputs for consistency
1300
1315
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1301
- this.engine.setInputs(nodeId, map);
1316
+ await client.setInputs(nodeId, map).catch(() => {
1317
+ // Ignore errors during launch - inputs will be set when user calls setInputs
1318
+ });
1302
1319
  }
1303
1320
  }
1304
1321
  /**
@@ -1340,9 +1357,11 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1340
1357
  this.engine = eng;
1341
1358
  this.runningKind = opts?.engine ?? "push";
1342
1359
  this.emit("status", { running: true, engine: this.runningKind });
1343
- // Re-apply staged inputs to new engine
1360
+ // Re-apply staged inputs using client.setInputs for consistency
1344
1361
  for (const [nodeId, map] of Object.entries(currentInputs)) {
1345
- this.engine.setInputs(nodeId, map);
1362
+ await client.setInputs(nodeId, map).catch(() => {
1363
+ // Ignore errors during engine switch - inputs will be set when user calls setInputs
1364
+ });
1346
1365
  }
1347
1366
  }
1348
1367
  async step() {
@@ -1357,7 +1376,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1357
1376
  const client = await this.ensureClient();
1358
1377
  await client.flush();
1359
1378
  }
1360
- setInputs(nodeId, inputs, options) {
1379
+ async setInputs(nodeId, inputs, options) {
1361
1380
  // Update staged inputs (for getInputs to work correctly)
1362
1381
  if (!this.stagedInputs[nodeId])
1363
1382
  this.stagedInputs[nodeId] = {};
@@ -1369,21 +1388,17 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1369
1388
  this.stagedInputs[nodeId][handle] = value;
1370
1389
  }
1371
1390
  }
1372
- // If engine exists, call directly; otherwise ensure client (fire-and-forget)
1373
- if (this.engine) {
1374
- this.engine.setInputs(nodeId, inputs, options);
1391
+ // Use transport.request instead of transport.send for consistency
1392
+ const client = await this.ensureClient();
1393
+ try {
1394
+ await client.setInputs(nodeId, inputs, options);
1375
1395
  }
1376
- else {
1377
- this.ensureClient()
1378
- .then((client) => {
1379
- client.getEngine().setInputs(nodeId, inputs, options);
1380
- })
1381
- .catch(() => {
1382
- // Emit synthetic events if connection fails
1383
- for (const [handle, value] of Object.entries(inputs)) {
1384
- this.emit("value", { nodeId, handle, value, io: "input" });
1385
- }
1386
- });
1396
+ catch (err) {
1397
+ // Emit synthetic events if connection fails
1398
+ for (const [handle, value] of Object.entries(inputs)) {
1399
+ this.emit("value", { nodeId, handle, value, io: "input" });
1400
+ }
1401
+ throw err;
1387
1402
  }
1388
1403
  }
1389
1404
  async copyOutputs(fromNodeId, toNodeId, options) {
@@ -1418,6 +1433,52 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1418
1433
  const client = await this.ensureClient();
1419
1434
  await client.setExtData(data);
1420
1435
  }
1436
+ async commit(reason) {
1437
+ const client = await this.ensureClient();
1438
+ try {
1439
+ await client.commit(reason);
1440
+ }
1441
+ catch (err) {
1442
+ console.error("[RemoteGraphRunner] Error committing:", err);
1443
+ throw err;
1444
+ }
1445
+ }
1446
+ async undo() {
1447
+ const client = await this.ensureClient();
1448
+ try {
1449
+ return await client.undo();
1450
+ }
1451
+ catch {
1452
+ return false;
1453
+ }
1454
+ }
1455
+ async redo() {
1456
+ const client = await this.ensureClient();
1457
+ try {
1458
+ return await client.redo();
1459
+ }
1460
+ catch {
1461
+ return false;
1462
+ }
1463
+ }
1464
+ async canUndo() {
1465
+ const client = await this.ensureClient();
1466
+ try {
1467
+ return await client.canUndo();
1468
+ }
1469
+ catch {
1470
+ return false;
1471
+ }
1472
+ }
1473
+ async canRedo() {
1474
+ const client = await this.ensureClient();
1475
+ try {
1476
+ return await client.canRedo();
1477
+ }
1478
+ catch {
1479
+ return false;
1480
+ }
1481
+ }
1421
1482
  async snapshotFull() {
1422
1483
  const client = await this.ensureClient();
1423
1484
  try {
@@ -1855,7 +1916,7 @@ function useWorkbenchBridge(wb) {
1855
1916
  wb.connect({
1856
1917
  source: { nodeId: params.source, handle: params.sourceHandle },
1857
1918
  target: { nodeId: params.target, handle: params.targetHandle },
1858
- });
1919
+ }, { commit: true });
1859
1920
  }, [wb]);
1860
1921
  const onNodesChange = React.useCallback((changes) => {
1861
1922
  // Apply position updates continuously, but mark commit only on drag end
@@ -1898,7 +1959,7 @@ function useWorkbenchBridge(wb) {
1898
1959
  });
1899
1960
  }
1900
1961
  }, [wb]);
1901
- const onEdgesDelete = React.useCallback((edges) => edges.forEach((e) => wb.disconnect(e.id)), [wb]);
1962
+ const onEdgesDelete = React.useCallback((edges) => edges.forEach((e, idx) => wb.disconnect(e.id, { commit: idx === edges.length - 1 })), [wb]);
1902
1963
  const onEdgesChange = React.useCallback((changes) => {
1903
1964
  const current = wb.getSelection();
1904
1965
  const nextEdgeIds = new Set(current.edges);
@@ -1934,8 +1995,7 @@ function useWorkbenchBridge(wb) {
1934
1995
  }
1935
1996
  }, [wb]);
1936
1997
  const onNodesDelete = React.useCallback((nodes) => {
1937
- for (const n of nodes)
1938
- wb.removeNode(n.id);
1998
+ nodes.forEach((n, idx) => wb.removeNode(n.id, { commit: idx === nodes.length - 1 }));
1939
1999
  }, [wb]);
1940
2000
  return {
1941
2001
  onConnect,
@@ -2628,7 +2688,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2628
2688
  }
2629
2689
  curX += maxWidth + H_GAP;
2630
2690
  }
2631
- wb.setPositions(pos, { commit: true });
2691
+ wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2632
2692
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2633
2693
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2634
2694
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -2870,7 +2930,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2870
2930
  }
2871
2931
  return add("workbench", "graphChanged")(event);
2872
2932
  });
2873
- const offWbGraphUiChanged = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2933
+ const offWbGraphUiChangedForLog = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2874
2934
  const offWbValidationChanged = wb.on("validationChanged", add("workbench", "validationChanged"));
2875
2935
  // Ensure newly added nodes start as invalidated until first evaluation
2876
2936
  const offWbAddNode = wb.on("graphChanged", (e) => {
@@ -2884,39 +2944,94 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2884
2944
  }
2885
2945
  });
2886
2946
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
2887
- if (!runner.isRunning())
2947
+ // Build detailed reason from change type
2948
+ let reason = "graph-changed";
2949
+ if (event.change) {
2950
+ const changeType = event.change.type;
2951
+ if (changeType === "addNode") {
2952
+ reason = "add-node";
2953
+ }
2954
+ else if (changeType === "removeNode") {
2955
+ reason = "remove-node";
2956
+ }
2957
+ else if (changeType === "connect") {
2958
+ reason = "connect-edge";
2959
+ }
2960
+ else if (changeType === "disconnect") {
2961
+ reason = "disconnect-edge";
2962
+ }
2963
+ else if (changeType === "updateParams") {
2964
+ reason = "update-node-params";
2965
+ }
2966
+ else if (changeType === "updateEdgeType") {
2967
+ reason = "update-edge-type";
2968
+ }
2969
+ }
2970
+ if (!runner.isRunning()) {
2971
+ if (event.commit) {
2972
+ // If runner not running, commit immediately (no update needed)
2973
+ await runner.commit(reason).catch((err) => {
2974
+ console.error("[WorkbenchContext] Error committing:", err);
2975
+ });
2976
+ }
2888
2977
  return;
2978
+ }
2889
2979
  try {
2890
2980
  if (event.change?.type === "addNode") {
2891
2981
  const { nodeId, inputs, copyOutputsFrom } = event.change;
2892
2982
  if (event.dry) {
2893
2983
  await runner.update(event.def, { dry: true });
2894
2984
  if (inputs) {
2895
- runner.setInputs(nodeId, inputs, { dry: true });
2985
+ await runner.setInputs(nodeId, inputs, { dry: true });
2896
2986
  }
2897
2987
  if (copyOutputsFrom) {
2898
- runner.copyOutputs(copyOutputsFrom, nodeId, { dry: true });
2988
+ await runner.copyOutputs(copyOutputsFrom, nodeId, { dry: true });
2899
2989
  }
2900
2990
  }
2901
2991
  else {
2902
2992
  await runner.update(event.def, { dry: !!inputs });
2903
2993
  if (inputs) {
2904
- runner.setInputs(nodeId, inputs, { dry: false });
2994
+ await runner.setInputs(nodeId, inputs, { dry: false });
2905
2995
  }
2906
2996
  }
2907
2997
  }
2908
2998
  else {
2909
2999
  await runner.update(event.def, { dry: event.dry });
2910
3000
  }
3001
+ if (event.commit) {
3002
+ // Wait for update to complete, then commit
3003
+ await runner.commit(event.reason ?? reason).catch((err) => {
3004
+ console.error("[WorkbenchContext] Error committing after update:", err);
3005
+ });
3006
+ }
2911
3007
  }
2912
3008
  catch (err) {
2913
3009
  console.error("[WorkbenchContext] Error updating graph:", err);
2914
3010
  }
2915
3011
  });
2916
3012
  const offWbdSetValidation = wb.on("validationChanged", (r) => setValidation(r));
2917
- const offWbSelectionChanged = wb.on("selectionChanged", (sel) => {
3013
+ const offWbSelectionChanged = wb.on("selectionChanged", async (sel) => {
2918
3014
  setSelectedNodeId(sel.nodes?.[0]);
2919
3015
  setSelectedEdgeId(sel.edges?.[0]);
3016
+ if (sel.commit) {
3017
+ // Commit on selection change
3018
+ await runner.commit(sel.reason ?? "selection").catch((err) => {
3019
+ console.error("[WorkbenchContext] Error committing selection change:", err);
3020
+ });
3021
+ }
3022
+ });
3023
+ const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
3024
+ // Only commit if commit flag is true (e.g., drag end, not during dragging)
3025
+ if (event.commit) {
3026
+ if (event.change) {
3027
+ event.change.type;
3028
+ }
3029
+ await runner
3030
+ .commit(event.reason ?? "ui-changed")
3031
+ .catch((err) => {
3032
+ console.error("[WorkbenchContext] Error committing UI changes:", err);
3033
+ });
3034
+ }
2920
3035
  });
2921
3036
  const offWbError = wb.on("error", add("workbench", "error"));
2922
3037
  // Registry updates: swap registry and refresh graph validation/UI
@@ -2953,6 +3068,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2953
3068
  offRunnerInvalidate();
2954
3069
  offRunnerStats();
2955
3070
  offWbGraphChanged();
3071
+ offWbGraphUiChangedForLog();
2956
3072
  offWbGraphUiChanged();
2957
3073
  offWbValidationChanged();
2958
3074
  offWbError();
@@ -3128,6 +3244,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3128
3244
  try {
3129
3245
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
3130
3246
  const raw = outputsMap?.[nodeId]?.[handleId];
3247
+ let newNodeId;
3131
3248
  if (!typeId || raw === undefined)
3132
3249
  return;
3133
3250
  const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
@@ -3153,23 +3270,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3153
3270
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3154
3271
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3155
3272
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3156
- wb.addNode({
3273
+ newNodeId = wb.addNode({
3157
3274
  typeId: singleTarget.nodeTypeId,
3158
3275
  position: { x: pos.x + 180, y: pos.y },
3159
3276
  }, { inputs: { [singleTarget.inputHandle]: coerced } });
3160
- return;
3161
3277
  }
3162
- if (isArray && arrTarget) {
3278
+ else if (isArray && arrTarget) {
3163
3279
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3164
3280
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3165
3281
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3166
- wb.addNode({
3282
+ newNodeId = wb.addNode({
3167
3283
  typeId: arrTarget.nodeTypeId,
3168
3284
  position: { x: pos.x + 180, y: pos.y },
3169
3285
  }, { inputs: { [arrTarget.inputHandle]: coerced } });
3170
- return;
3171
3286
  }
3172
- if (isArray && elemTarget) {
3287
+ else if (isArray && elemTarget) {
3173
3288
  const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3174
3289
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3175
3290
  const src = unwrap(raw);
@@ -3181,19 +3296,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3181
3296
  for (let idx = 0; idx < coercedItems.length; idx++) {
3182
3297
  const col = idx % COLS;
3183
3298
  const row = Math.floor(idx / COLS);
3184
- wb.addNode({
3299
+ newNodeId = wb.addNode({
3185
3300
  typeId: elemTarget.nodeTypeId,
3186
3301
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3187
3302
  }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3188
3303
  }
3189
- return;
3304
+ }
3305
+ if (newNodeId) {
3306
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3190
3307
  }
3191
3308
  }
3192
3309
  catch { }
3193
3310
  };
3194
3311
  return {
3195
3312
  onDelete: () => {
3196
- wb.removeNode(nodeId);
3313
+ wb.removeNode(nodeId, { commit: true });
3197
3314
  onClose();
3198
3315
  },
3199
3316
  onDuplicate: async () => {
@@ -3218,10 +3335,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3218
3335
  dry: true,
3219
3336
  });
3220
3337
  // Select the newly duplicated node
3221
- wb.setSelection({
3222
- nodes: [newNodeId],
3223
- edges: [],
3224
- });
3338
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3225
3339
  onClose();
3226
3340
  },
3227
3341
  onDuplicateWithEdges: async () => {
@@ -3254,10 +3368,9 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3254
3368
  }, { dry: true });
3255
3369
  }
3256
3370
  // Select the newly duplicated node and edges
3257
- wb.setSelection({
3258
- nodes: [newNodeId],
3259
- edges: [],
3260
- });
3371
+ if (newNodeId) {
3372
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3373
+ }
3261
3374
  onClose();
3262
3375
  },
3263
3376
  onRunPull: async () => {
@@ -3331,7 +3444,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3331
3444
  onClose();
3332
3445
  },
3333
3446
  onDelete: () => {
3334
- wb.deleteSelection();
3447
+ wb.deleteSelection({ commit: true, reason: "delete-selection" });
3335
3448
  onClose();
3336
3449
  },
3337
3450
  onClose,
@@ -3340,10 +3453,24 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3340
3453
  /**
3341
3454
  * Creates base default context menu handlers.
3342
3455
  */
3343
- function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste) {
3456
+ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData) {
3457
+ // Wrap paste handler to clear storage after paste
3458
+ const wrappedOnPaste = onPaste && getCopiedData && clearCopiedData
3459
+ ? (position) => {
3460
+ onPaste(position);
3461
+ clearCopiedData();
3462
+ }
3463
+ : onPaste;
3464
+ // Function to check if paste data exists (called dynamically when menu opens)
3465
+ const hasPasteData = getCopiedData ? () => !!getCopiedData() : undefined;
3344
3466
  return {
3345
3467
  onAddNode,
3346
- onPaste,
3468
+ onPaste: wrappedOnPaste,
3469
+ hasPasteData,
3470
+ onUndo: runner ? () => runner.undo().then(() => onClose()) : undefined,
3471
+ onRedo: runner ? () => runner.redo().then(() => onClose()) : undefined,
3472
+ canUndo: runner ? () => runner.canUndo().catch(() => false) : undefined,
3473
+ canRedo: runner ? () => runner.canRedo().catch(() => false) : undefined,
3347
3474
  onClose,
3348
3475
  };
3349
3476
  }
@@ -3867,13 +3994,48 @@ function DefaultNodeContent({ data, isConnectable, }) {
3867
3994
  } })] }));
3868
3995
  }
3869
3996
 
3870
- function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3997
+ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enableKeyboardShortcuts = true, keyboardShortcuts = {
3998
+ undo: "⌘/Ctrl + Z",
3999
+ redo: "⌘/Ctrl + Shift + Z",
4000
+ paste: "⌘/Ctrl + V",
4001
+ }, }) {
3871
4002
  const rf = react.useReactFlow();
3872
4003
  const [query, setQuery] = React.useState("");
4004
+ const [canUndo, setCanUndo] = React.useState(false);
4005
+ const [canRedo, setCanRedo] = React.useState(false);
4006
+ const [hasPasteData, setHasPasteData] = React.useState(false);
3873
4007
  const q = query.trim().toLowerCase();
3874
4008
  const filteredIds = q
3875
4009
  ? nodeIds.filter((id) => id.toLowerCase().includes(q))
3876
4010
  : nodeIds;
4011
+ // Check undo/redo availability and paste data when menu opens
4012
+ React.useEffect(() => {
4013
+ if (!open)
4014
+ return;
4015
+ let cancelled = false;
4016
+ const checkAvailability = async () => {
4017
+ if (handlers.canUndo) {
4018
+ const result = await handlers.canUndo();
4019
+ if (!cancelled)
4020
+ setCanUndo(result);
4021
+ }
4022
+ if (handlers.canRedo) {
4023
+ const result = await handlers.canRedo();
4024
+ if (!cancelled)
4025
+ setCanRedo(result);
4026
+ }
4027
+ // Check paste data dynamically
4028
+ if (handlers.hasPasteData) {
4029
+ const result = handlers.hasPasteData();
4030
+ if (!cancelled)
4031
+ setHasPasteData(result);
4032
+ }
4033
+ };
4034
+ checkAvailability();
4035
+ return () => {
4036
+ cancelled = true;
4037
+ };
4038
+ }, [open, handlers.canUndo, handlers.canRedo, handlers.hasPasteData]);
3877
4039
  const root = { __children: {} };
3878
4040
  for (const id of filteredIds) {
3879
4041
  const parts = id.split(".");
@@ -3938,6 +4100,12 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3938
4100
  handlers.onPaste(p);
3939
4101
  handlers.onClose();
3940
4102
  };
4103
+ // Helper to format shortcut for current platform
4104
+ const formatShortcut = (shortcut) => {
4105
+ const isMac = typeof navigator !== "undefined" &&
4106
+ navigator.userAgent.toLowerCase().includes("mac");
4107
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4108
+ };
3941
4109
  const renderTree = (tree, path = []) => {
3942
4110
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
3943
4111
  return (jsxRuntime.jsx("div", { children: entries.map(([key, child]) => {
@@ -3955,10 +4123,17 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3955
4123
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3956
4124
  e.preventDefault();
3957
4125
  e.stopPropagation();
3958
- }, children: [handlers.onPaste && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed", onClick: handlePaste, children: "Paste" })), handlers.onPaste && jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxRuntime.jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsxRuntime.jsx("div", { className: "px-2 pb-1", children: jsxRuntime.jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Filter nodes...", className: "w-full border border-gray-300 px-2 py-1 text-sm outline-none focus:border-gray-400 select-text", onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation() }) }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsxRuntime.jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
4126
+ }, children: [hasPasteData && handlers.onPaste && (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlePaste, children: [jsxRuntime.jsx("span", { children: "Paste" }), enableKeyboardShortcuts && keyboardShortcuts.paste && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.paste) }))] })), (handlers.onUndo || handlers.onRedo) && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [hasPasteData && handlers.onPaste && (jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlers.onUndo, disabled: !canUndo, children: [jsxRuntime.jsx("span", { children: "Undo" }), enableKeyboardShortcuts && keyboardShortcuts.undo && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.undo) }))] })), handlers.onRedo && (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlers.onRedo, disabled: !canRedo, children: [jsxRuntime.jsx("span", { children: "Redo" }), enableKeyboardShortcuts && keyboardShortcuts.redo && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.redo) }))] }))] })), hasPasteData &&
4127
+ handlers.onPaste &&
4128
+ !handlers.onUndo &&
4129
+ !handlers.onRedo && jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxRuntime.jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsxRuntime.jsx("div", { className: "px-2 pb-1", children: jsxRuntime.jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Filter nodes...", className: "w-full border border-gray-300 rounded px-2 py-1 text-sm outline-none focus:border-gray-400 select-text", onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation() }) }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsxRuntime.jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
3959
4130
  }
3960
4131
 
3961
- function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
4132
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, enableKeyboardShortcuts = true, keyboardShortcuts = {
4133
+ copy: "⌘/Ctrl + C",
4134
+ duplicate: "⌘/Ctrl + D",
4135
+ delete: "Delete",
4136
+ }, }) {
3962
4137
  const ref = React.useRef(null);
3963
4138
  // outside click + ESC
3964
4139
  React.useEffect(() => {
@@ -3985,6 +4160,12 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
3985
4160
  if (open)
3986
4161
  ref.current?.focus();
3987
4162
  }, [open]);
4163
+ // Helper to format shortcut for current platform
4164
+ const formatShortcut = (shortcut) => {
4165
+ const isMac = typeof navigator !== "undefined" &&
4166
+ navigator.userAgent.toLowerCase().includes("mac");
4167
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4168
+ };
3988
4169
  if (!open || !clientPos || !nodeId)
3989
4170
  return null;
3990
4171
  // clamp
@@ -3996,10 +4177,13 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
3996
4177
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3997
4178
  e.preventDefault();
3998
4179
  e.stopPropagation();
3999
- }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopy, children: "Copy" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4180
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDelete, children: [jsxRuntime.jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] }), jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDuplicate, children: [jsxRuntime.jsx("span", { children: "Duplicate" }), enableKeyboardShortcuts && keyboardShortcuts.duplicate && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.duplicate) }))] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onCopy, children: [jsxRuntime.jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4000
4181
  }
4001
4182
 
4002
- function SelectionContextMenu({ open, clientPos, handlers, }) {
4183
+ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
4184
+ copy: "⌘/Ctrl + C",
4185
+ delete: "Delete",
4186
+ }, }) {
4003
4187
  const ref = React.useRef(null);
4004
4188
  // Close on outside click and on ESC
4005
4189
  React.useEffect(() => {
@@ -4026,6 +4210,12 @@ function SelectionContextMenu({ open, clientPos, handlers, }) {
4026
4210
  if (open)
4027
4211
  ref.current?.focus();
4028
4212
  }, [open]);
4213
+ // Helper to format shortcut for current platform
4214
+ const formatShortcut = (shortcut) => {
4215
+ const isMac = typeof navigator !== "undefined" &&
4216
+ navigator.userAgent.toLowerCase().includes("mac");
4217
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4218
+ };
4029
4219
  if (!open || !clientPos)
4030
4220
  return null;
4031
4221
  // Clamp menu position to viewport
@@ -4037,7 +4227,7 @@ function SelectionContextMenu({ open, clientPos, handlers, }) {
4037
4227
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
4038
4228
  e.preventDefault();
4039
4229
  e.stopPropagation();
4040
- }, children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopy, children: "Copy" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" })] }));
4230
+ }, children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onCopy, children: [jsxRuntime.jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDelete, children: [jsxRuntime.jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] })] }));
4041
4231
  }
4042
4232
 
4043
4233
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -4375,7 +4565,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4375
4565
  setNodeMenuOpen(false);
4376
4566
  setSelectionMenuOpen(false);
4377
4567
  };
4378
- const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs }), [wb]);
4568
+ const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs, commit: true }), [wb]);
4379
4569
  const onCloseMenu = React.useCallback(() => {
4380
4570
  setMenuOpen(false);
4381
4571
  }, []);
@@ -4407,14 +4597,14 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4407
4597
  const data = storage.get();
4408
4598
  if (!data)
4409
4599
  return;
4410
- wb.pasteCopiedData(data, position);
4600
+ wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4411
4601
  onCloseMenu();
4412
- });
4602
+ }, runner, () => storage.get(), () => storage.set(null));
4413
4603
  if (overrides?.getDefaultContextMenuHandlers) {
4414
4604
  return overrides.getDefaultContextMenuHandlers(wb, baseHandlers);
4415
4605
  }
4416
4606
  return baseHandlers;
4417
- }, [addNodeAt, onCloseMenu, overrides, wb]);
4607
+ }, [addNodeAt, onCloseMenu, overrides, wb, runner]);
4418
4608
  const selectionContextMenuHandlers = React.useMemo(() => {
4419
4609
  // Get storage from override or use workbench's internal storage
4420
4610
  const storage = overrides?.getCopiedDataStorage
@@ -4428,9 +4618,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4428
4618
  }, runner);
4429
4619
  if (overrides?.getSelectionContextMenuHandlers) {
4430
4620
  const selection = wb.getSelection();
4431
- return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, {
4432
- getDefaultNodeSize: overrides.getDefaultNodeSize,
4433
- });
4621
+ return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, { getDefaultNodeSize: overrides.getDefaultNodeSize });
4434
4622
  }
4435
4623
  return baseHandlers;
4436
4624
  }, [wb, runner, overrides, onCloseSelectionMenu]);
@@ -4469,6 +4657,116 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4469
4657
  return [];
4470
4658
  return getBakeableOutputs(nodeAtMenu, wb, registry, outputTypesMap);
4471
4659
  }, [nodeAtMenu, wb, registry, outputTypesMap]);
4660
+ // Keyboard shortcuts configuration
4661
+ const enableKeyboardShortcuts = overrides?.enableKeyboardShortcuts !== false; // Default to true
4662
+ const keyboardShortcuts = overrides?.keyboardShortcuts || {
4663
+ undo: "⌘/Ctrl + Z",
4664
+ redo: "⌘/Ctrl + Shift + Z",
4665
+ copy: "⌘/Ctrl + C",
4666
+ paste: "⌘/Ctrl + V",
4667
+ duplicate: "⌘/Ctrl + D",
4668
+ delete: "Delete",
4669
+ };
4670
+ // Keyboard shortcut handler
4671
+ React.useEffect(() => {
4672
+ if (!enableKeyboardShortcuts)
4673
+ return;
4674
+ const handleKeyDown = async (e) => {
4675
+ // Ignore if typing in input/textarea
4676
+ const target = e.target;
4677
+ if (target.tagName === "INPUT" ||
4678
+ target.tagName === "TEXTAREA" ||
4679
+ target.isContentEditable) {
4680
+ return;
4681
+ }
4682
+ // Detect Mac platform using userAgent (navigator.platform is deprecated)
4683
+ const isMac = typeof navigator !== "undefined" &&
4684
+ navigator.userAgent.toLowerCase().includes("mac");
4685
+ const modKey = isMac ? e.metaKey : e.ctrlKey;
4686
+ const key = e.key.toLowerCase();
4687
+ // Undo: Cmd/Ctrl + Z
4688
+ if (modKey && key === "z" && !e.shiftKey && !e.altKey) {
4689
+ e.preventDefault();
4690
+ if (runner &&
4691
+ "onUndo" in defaultContextMenuHandlers &&
4692
+ defaultContextMenuHandlers.onUndo) {
4693
+ const canUndo = await runner.canUndo().catch(() => false);
4694
+ if (canUndo) {
4695
+ defaultContextMenuHandlers.onUndo();
4696
+ }
4697
+ }
4698
+ return;
4699
+ }
4700
+ // Redo: Cmd/Ctrl + Shift + Z
4701
+ if (modKey && e.shiftKey && key === "z" && !e.altKey) {
4702
+ e.preventDefault();
4703
+ if (runner &&
4704
+ "onRedo" in defaultContextMenuHandlers &&
4705
+ defaultContextMenuHandlers.onRedo) {
4706
+ const canRedo = await runner.canRedo().catch(() => false);
4707
+ if (canRedo) {
4708
+ defaultContextMenuHandlers.onRedo();
4709
+ }
4710
+ }
4711
+ return;
4712
+ }
4713
+ // Copy: Cmd/Ctrl + C
4714
+ if (modKey && key === "c" && !e.shiftKey && !e.altKey) {
4715
+ const selection = wb.getSelection();
4716
+ if (selection.nodes.length > 0 || selection.edges.length > 0) {
4717
+ e.preventDefault();
4718
+ // If single node selected, use node context menu handler; otherwise use selection handler
4719
+ if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
4720
+ nodeContextMenuHandlers.onCopy();
4721
+ }
4722
+ else if (selectionContextMenuHandlers.onCopy) {
4723
+ selectionContextMenuHandlers.onCopy();
4724
+ }
4725
+ }
4726
+ return;
4727
+ }
4728
+ // Duplicate: Cmd/Ctrl + D
4729
+ if (modKey && key === "d" && !e.shiftKey && !e.altKey) {
4730
+ const selection = wb.getSelection();
4731
+ if (selection.nodes.length === 1 &&
4732
+ nodeContextMenuHandlers?.onDuplicate) {
4733
+ e.preventDefault();
4734
+ nodeContextMenuHandlers.onDuplicate();
4735
+ }
4736
+ return;
4737
+ }
4738
+ // Paste: Cmd/Ctrl + V
4739
+ if (modKey && key === "v" && !e.shiftKey && !e.altKey) {
4740
+ e.preventDefault();
4741
+ if ("hasPasteData" in defaultContextMenuHandlers &&
4742
+ defaultContextMenuHandlers.hasPasteData &&
4743
+ defaultContextMenuHandlers.hasPasteData() &&
4744
+ "onPaste" in defaultContextMenuHandlers &&
4745
+ defaultContextMenuHandlers.onPaste) {
4746
+ const center = rfInstanceRef.current?.screenToFlowPosition({
4747
+ x: window.innerWidth / 2,
4748
+ y: window.innerHeight / 2,
4749
+ }) || { x: 0, y: 0 };
4750
+ defaultContextMenuHandlers.onPaste(center);
4751
+ }
4752
+ return;
4753
+ }
4754
+ // Note: Delete/Backspace is handled by ReactFlow's deleteKeyCode prop
4755
+ // which triggers onNodesDelete/onEdgesDelete, so we don't need to handle it here
4756
+ };
4757
+ window.addEventListener("keydown", handleKeyDown);
4758
+ return () => {
4759
+ window.removeEventListener("keydown", handleKeyDown);
4760
+ };
4761
+ }, [
4762
+ enableKeyboardShortcuts,
4763
+ wb,
4764
+ runner,
4765
+ defaultContextMenuHandlers,
4766
+ selectionContextMenuHandlers,
4767
+ nodeContextMenuHandlers,
4768
+ rfInstanceRef,
4769
+ ]);
4472
4770
  // Get custom renderers from UI extension registry (reactive to uiVersion changes)
4473
4771
  const { BackgroundRenderer, MinimapRenderer, ControlsRenderer, DefaultContextMenuRenderer, NodeContextMenuRenderer, connectionLineRenderer, } = React.useMemo(() => {
4474
4772
  return {
@@ -4483,7 +4781,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4483
4781
  const onMoveEnd = React.useCallback(() => {
4484
4782
  if (rfInstanceRef.current) {
4485
4783
  const viewport = rfInstanceRef.current.getViewport();
4486
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
4784
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
4487
4785
  }
4488
4786
  }, [wb]);
4489
4787
  const viewportRef = React.useRef(null);
@@ -4515,9 +4813,13 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4515
4813
  zoom: savedViewport.zoom,
4516
4814
  });
4517
4815
  }
4518
- }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [BackgroundRenderer ? (jsxRuntime.jsx(BackgroundRenderer, {})) : (jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsxRuntime.jsx(MinimapRenderer, {}) : jsxRuntime.jsx(react.MiniMap, {}), ControlsRenderer ? jsxRuntime.jsx(ControlsRenderer, {}) : jsxRuntime.jsx(react.Controls, {}), DefaultContextMenuRenderer ? (jsxRuntime.jsx(DefaultContextMenuRenderer, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds })) : (jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds })), !!nodeAtMenu &&
4816
+ }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [BackgroundRenderer ? (jsxRuntime.jsx(BackgroundRenderer, {})) : (jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsxRuntime.jsx(MinimapRenderer, {}) : jsxRuntime.jsx(react.MiniMap, {}), ControlsRenderer ? jsxRuntime.jsx(ControlsRenderer, {}) : jsxRuntime.jsx(react.Controls, {}), DefaultContextMenuRenderer ? (jsxRuntime.jsx(DefaultContextMenuRenderer, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
4817
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4818
+ : {}) })) : (jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
4519
4819
  nodeContextMenuHandlers &&
4520
- (NodeContextMenuRenderer ? (jsxRuntime.jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs })) : (jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs }))), selectionMenuOpen && selectionMenuPos && (jsxRuntime.jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers }))] }) }) }));
4820
+ (NodeContextMenuRenderer ? (jsxRuntime.jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, ...(enableKeyboardShortcuts !== false
4821
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4822
+ : {}) })) : (jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))), selectionMenuOpen && selectionMenuPos && (jsxRuntime.jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
4521
4823
  });
4522
4824
 
4523
4825
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {