@bian-womp/spark-workbench 0.2.69 → 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 (45) hide show
  1. package/lib/cjs/index.cjs +125 -69
  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/NodeContextMenu.d.ts +1 -1
  8. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +6 -0
  11. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +1 -0
  14. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  16. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  17. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +1 -1
  18. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  19. package/lib/cjs/src/runtime/IGraphRunner.d.ts +1 -1
  20. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  21. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +1 -1
  22. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  23. package/lib/esm/index.js +125 -69
  24. package/lib/esm/index.js.map +1 -1
  25. package/lib/esm/src/core/InMemoryWorkbench.d.ts +27 -7
  26. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  27. package/lib/esm/src/core/contracts.d.ts +5 -0
  28. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  29. package/lib/esm/src/misc/NodeContextMenu.d.ts +1 -1
  30. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  31. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  32. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +6 -0
  33. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  34. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  35. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +1 -0
  36. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  37. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  38. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  39. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +1 -1
  40. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  41. package/lib/esm/src/runtime/IGraphRunner.d.ts +1 -1
  42. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  43. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +1 -1
  44. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  45. 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
  /**
@@ -686,7 +686,7 @@ class AbstractGraphRunner {
686
686
  return false;
687
687
  }
688
688
  // Optional commit support
689
- async commit() { }
689
+ async commit(_reason) { }
690
690
  }
691
691
 
692
692
  // Counter for generating readable runner IDs
@@ -1433,10 +1433,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1433
1433
  const client = await this.ensureClient();
1434
1434
  await client.setExtData(data);
1435
1435
  }
1436
- async commit() {
1436
+ async commit(reason) {
1437
1437
  const client = await this.ensureClient();
1438
1438
  try {
1439
- await client.commit();
1439
+ await client.commit(reason);
1440
1440
  }
1441
1441
  catch (err) {
1442
1442
  console.error("[RemoteGraphRunner] Error committing:", err);
@@ -1916,7 +1916,7 @@ function useWorkbenchBridge(wb) {
1916
1916
  wb.connect({
1917
1917
  source: { nodeId: params.source, handle: params.sourceHandle },
1918
1918
  target: { nodeId: params.target, handle: params.targetHandle },
1919
- });
1919
+ }, { commit: true });
1920
1920
  }, [wb]);
1921
1921
  const onNodesChange = React.useCallback((changes) => {
1922
1922
  // Apply position updates continuously, but mark commit only on drag end
@@ -1959,7 +1959,7 @@ function useWorkbenchBridge(wb) {
1959
1959
  });
1960
1960
  }
1961
1961
  }, [wb]);
1962
- 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]);
1963
1963
  const onEdgesChange = React.useCallback((changes) => {
1964
1964
  const current = wb.getSelection();
1965
1965
  const nextEdgeIds = new Set(current.edges);
@@ -1995,8 +1995,7 @@ function useWorkbenchBridge(wb) {
1995
1995
  }
1996
1996
  }, [wb]);
1997
1997
  const onNodesDelete = React.useCallback((nodes) => {
1998
- for (const n of nodes)
1999
- wb.removeNode(n.id);
1998
+ nodes.forEach((n, idx) => wb.removeNode(n.id, { commit: idx === nodes.length - 1 }));
2000
1999
  }, [wb]);
2001
2000
  return {
2002
2001
  onConnect,
@@ -2689,7 +2688,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2689
2688
  }
2690
2689
  curX += maxWidth + H_GAP;
2691
2690
  }
2692
- wb.setPositions(pos, { commit: true });
2691
+ wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2693
2692
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2694
2693
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2695
2694
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -2945,11 +2944,36 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2945
2944
  }
2946
2945
  });
2947
2946
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
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
+ }
2948
2970
  if (!runner.isRunning()) {
2949
- // If runner not running, commit immediately (no update needed)
2950
- await runner.commit().catch((err) => {
2951
- console.error("[WorkbenchContext] Error committing:", err);
2952
- });
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
+ }
2953
2977
  return;
2954
2978
  }
2955
2979
  try {
@@ -2974,10 +2998,12 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2974
2998
  else {
2975
2999
  await runner.update(event.def, { dry: event.dry });
2976
3000
  }
2977
- // Wait for update to complete, then commit
2978
- await runner.commit().catch((err) => {
2979
- console.error("[WorkbenchContext] Error committing after update:", err);
2980
- });
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
+ }
2981
3007
  }
2982
3008
  catch (err) {
2983
3009
  console.error("[WorkbenchContext] Error updating graph:", err);
@@ -2987,15 +3013,22 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2987
3013
  const offWbSelectionChanged = wb.on("selectionChanged", async (sel) => {
2988
3014
  setSelectedNodeId(sel.nodes?.[0]);
2989
3015
  setSelectedEdgeId(sel.edges?.[0]);
2990
- // Commit on selection change
2991
- await runner.commit().catch((err) => {
2992
- console.error("[WorkbenchContext] Error committing selection change:", err);
2993
- });
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
+ }
2994
3022
  });
2995
3023
  const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
2996
3024
  // Only commit if commit flag is true (e.g., drag end, not during dragging)
2997
3025
  if (event.commit) {
2998
- await runner.commit().catch((err) => {
3026
+ if (event.change) {
3027
+ event.change.type;
3028
+ }
3029
+ await runner
3030
+ .commit(event.reason ?? "ui-changed")
3031
+ .catch((err) => {
2999
3032
  console.error("[WorkbenchContext] Error committing UI changes:", err);
3000
3033
  });
3001
3034
  }
@@ -3211,6 +3244,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3211
3244
  try {
3212
3245
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
3213
3246
  const raw = outputsMap?.[nodeId]?.[handleId];
3247
+ let newNodeId;
3214
3248
  if (!typeId || raw === undefined)
3215
3249
  return;
3216
3250
  const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
@@ -3236,23 +3270,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3236
3270
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3237
3271
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3238
3272
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3239
- wb.addNode({
3273
+ newNodeId = wb.addNode({
3240
3274
  typeId: singleTarget.nodeTypeId,
3241
3275
  position: { x: pos.x + 180, y: pos.y },
3242
3276
  }, { inputs: { [singleTarget.inputHandle]: coerced } });
3243
- return;
3244
3277
  }
3245
- if (isArray && arrTarget) {
3278
+ else if (isArray && arrTarget) {
3246
3279
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3247
3280
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3248
3281
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3249
- wb.addNode({
3282
+ newNodeId = wb.addNode({
3250
3283
  typeId: arrTarget.nodeTypeId,
3251
3284
  position: { x: pos.x + 180, y: pos.y },
3252
3285
  }, { inputs: { [arrTarget.inputHandle]: coerced } });
3253
- return;
3254
3286
  }
3255
- if (isArray && elemTarget) {
3287
+ else if (isArray && elemTarget) {
3256
3288
  const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3257
3289
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3258
3290
  const src = unwrap(raw);
@@ -3264,19 +3296,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3264
3296
  for (let idx = 0; idx < coercedItems.length; idx++) {
3265
3297
  const col = idx % COLS;
3266
3298
  const row = Math.floor(idx / COLS);
3267
- wb.addNode({
3299
+ newNodeId = wb.addNode({
3268
3300
  typeId: elemTarget.nodeTypeId,
3269
3301
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3270
3302
  }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3271
3303
  }
3272
- return;
3304
+ }
3305
+ if (newNodeId) {
3306
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3273
3307
  }
3274
3308
  }
3275
3309
  catch { }
3276
3310
  };
3277
3311
  return {
3278
3312
  onDelete: () => {
3279
- wb.removeNode(nodeId);
3313
+ wb.removeNode(nodeId, { commit: true });
3280
3314
  onClose();
3281
3315
  },
3282
3316
  onDuplicate: async () => {
@@ -3301,10 +3335,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3301
3335
  dry: true,
3302
3336
  });
3303
3337
  // Select the newly duplicated node
3304
- wb.setSelection({
3305
- nodes: [newNodeId],
3306
- edges: [],
3307
- });
3338
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3308
3339
  onClose();
3309
3340
  },
3310
3341
  onDuplicateWithEdges: async () => {
@@ -3337,10 +3368,9 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3337
3368
  }, { dry: true });
3338
3369
  }
3339
3370
  // Select the newly duplicated node and edges
3340
- wb.setSelection({
3341
- nodes: [newNodeId],
3342
- edges: [],
3343
- });
3371
+ if (newNodeId) {
3372
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3373
+ }
3344
3374
  onClose();
3345
3375
  },
3346
3376
  onRunPull: async () => {
@@ -3414,7 +3444,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3414
3444
  onClose();
3415
3445
  },
3416
3446
  onDelete: () => {
3417
- wb.deleteSelection();
3447
+ wb.deleteSelection({ commit: true, reason: "delete-selection" });
3418
3448
  onClose();
3419
3449
  },
3420
3450
  onClose,
@@ -4096,10 +4126,14 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4096
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 &&
4097
4127
  handlers.onPaste &&
4098
4128
  !handlers.onUndo &&
4099
- !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 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" })) })] }));
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" })) })] }));
4100
4130
  }
4101
4131
 
4102
- 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
+ }, }) {
4103
4137
  const ref = React.useRef(null);
4104
4138
  // outside click + ESC
4105
4139
  React.useEffect(() => {
@@ -4126,6 +4160,12 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4126
4160
  if (open)
4127
4161
  ref.current?.focus();
4128
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
+ };
4129
4169
  if (!open || !clientPos || !nodeId)
4130
4170
  return null;
4131
4171
  // clamp
@@ -4137,7 +4177,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4137
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) => {
4138
4178
  e.preventDefault();
4139
4179
  e.stopPropagation();
4140
- }, 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)))] }))] }));
4141
4181
  }
4142
4182
 
4143
4183
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -4525,7 +4565,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4525
4565
  setNodeMenuOpen(false);
4526
4566
  setSelectionMenuOpen(false);
4527
4567
  };
4528
- 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]);
4529
4569
  const onCloseMenu = React.useCallback(() => {
4530
4570
  setMenuOpen(false);
4531
4571
  }, []);
@@ -4557,7 +4597,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4557
4597
  const data = storage.get();
4558
4598
  if (!data)
4559
4599
  return;
4560
- wb.pasteCopiedData(data, position);
4600
+ wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4561
4601
  onCloseMenu();
4562
4602
  }, runner, () => storage.get(), () => storage.set(null));
4563
4603
  if (overrides?.getDefaultContextMenuHandlers) {
@@ -4578,9 +4618,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4578
4618
  }, runner);
4579
4619
  if (overrides?.getSelectionContextMenuHandlers) {
4580
4620
  const selection = wb.getSelection();
4581
- return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, {
4582
- getDefaultNodeSize: overrides.getDefaultNodeSize,
4583
- });
4621
+ return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, { getDefaultNodeSize: overrides.getDefaultNodeSize });
4584
4622
  }
4585
4623
  return baseHandlers;
4586
4624
  }, [wb, runner, overrides, onCloseSelectionMenu]);
@@ -4626,6 +4664,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4626
4664
  redo: "⌘/Ctrl + Shift + Z",
4627
4665
  copy: "⌘/Ctrl + C",
4628
4666
  paste: "⌘/Ctrl + V",
4667
+ duplicate: "⌘/Ctrl + D",
4629
4668
  delete: "Delete",
4630
4669
  };
4631
4670
  // Keyboard shortcut handler
@@ -4676,12 +4715,26 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4676
4715
  const selection = wb.getSelection();
4677
4716
  if (selection.nodes.length > 0 || selection.edges.length > 0) {
4678
4717
  e.preventDefault();
4679
- if (selectionContextMenuHandlers.onCopy) {
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) {
4680
4723
  selectionContextMenuHandlers.onCopy();
4681
4724
  }
4682
4725
  }
4683
4726
  return;
4684
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
+ }
4685
4738
  // Paste: Cmd/Ctrl + V
4686
4739
  if (modKey && key === "v" && !e.shiftKey && !e.altKey) {
4687
4740
  e.preventDefault();
@@ -4711,6 +4764,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4711
4764
  runner,
4712
4765
  defaultContextMenuHandlers,
4713
4766
  selectionContextMenuHandlers,
4767
+ nodeContextMenuHandlers,
4714
4768
  rfInstanceRef,
4715
4769
  ]);
4716
4770
  // Get custom renderers from UI extension registry (reactive to uiVersion changes)
@@ -4727,7 +4781,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4727
4781
  const onMoveEnd = React.useCallback(() => {
4728
4782
  if (rfInstanceRef.current) {
4729
4783
  const viewport = rfInstanceRef.current.getViewport();
4730
- 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 });
4731
4785
  }
4732
4786
  }, [wb]);
4733
4787
  const viewportRef = React.useRef(null);
@@ -4763,7 +4817,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4763
4817
  ? { enableKeyboardShortcuts, keyboardShortcuts }
4764
4818
  : {}) })) : (jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
4765
4819
  nodeContextMenuHandlers &&
4766
- (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, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
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 }))] }) }) }));
4767
4823
  });
4768
4824
 
4769
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, }) {