@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/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generateId, GraphBuilder, createEngine, StepEngine, PullEngine, BatchedEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
2
+ import lod from 'lodash';
2
3
  import { RuntimeApiClient } from '@bian-womp/spark-remote';
3
4
  import { Position, Handle, useUpdateNodeInternals, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
4
5
  import React, { useCallback, useState, useRef, useEffect, useMemo, createContext, useContext, useImperativeHandle } from 'react';
@@ -208,18 +209,19 @@ class InMemoryWorkbench extends AbstractWorkbench {
208
209
  inputs: options?.inputs,
209
210
  copyOutputsFrom: options?.copyOutputsFrom,
210
211
  },
211
- dry: options?.dry,
212
+ ...lod.pick(options, ["dry", "commit", "reason"]),
212
213
  });
213
214
  this.refreshValidation();
214
215
  return id;
215
216
  }
216
- removeNode(nodeId) {
217
+ removeNode(nodeId, options) {
217
218
  this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
218
219
  this.def.edges = this.def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
219
220
  delete this.positions[nodeId];
220
221
  this.emit("graphChanged", {
221
222
  def: this.def,
222
223
  change: { type: "removeNode", nodeId },
224
+ ...options,
223
225
  });
224
226
  this.refreshValidation();
225
227
  }
@@ -234,16 +236,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
234
236
  this.emit("graphChanged", {
235
237
  def: this.def,
236
238
  change: { type: "connect", edgeId: id },
237
- dry: options?.dry,
239
+ ...options,
238
240
  });
239
241
  this.refreshValidation();
240
242
  return id;
241
243
  }
242
- disconnect(edgeId) {
244
+ disconnect(edgeId, options) {
243
245
  this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
244
246
  this.emit("graphChanged", {
245
247
  def: this.def,
246
248
  change: { type: "disconnect", edgeId },
249
+ ...options,
247
250
  });
248
251
  this.emit("validationChanged", this.validate());
249
252
  }
@@ -272,32 +275,32 @@ class InMemoryWorkbench extends AbstractWorkbench {
272
275
  });
273
276
  }
274
277
  // Position and selection APIs for React Flow bridge
275
- setPosition(nodeId, pos, opts) {
278
+ setPosition(nodeId, pos, options) {
276
279
  this.positions[nodeId] = pos;
277
280
  this.emit("graphUiChanged", {
278
281
  def: this.def,
279
282
  change: { type: "moveNode", nodeId, pos },
280
- commit: !!opts?.commit === true,
283
+ ...options,
281
284
  });
282
285
  }
283
- setPositions(map, opts) {
286
+ setPositions(map, options) {
284
287
  this.positions = { ...map };
285
288
  this.emit("graphUiChanged", {
286
289
  def: this.def,
287
290
  change: { type: "moveNodes" },
288
- commit: opts?.commit,
291
+ ...options,
289
292
  });
290
293
  }
291
294
  getPositions() {
292
295
  return { ...this.positions };
293
296
  }
294
- setSelection(sel, opts) {
297
+ setSelection(sel, options) {
295
298
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
296
299
  this.emit("selectionChanged", this.selection);
297
300
  this.emit("graphUiChanged", {
298
301
  def: this.def,
299
302
  change: { type: "selection" },
300
- commit: opts?.commit,
303
+ ...options,
301
304
  });
302
305
  }
303
306
  getSelection() {
@@ -309,7 +312,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
309
312
  /**
310
313
  * Delete all selected nodes and edges.
311
314
  */
312
- deleteSelection() {
315
+ deleteSelection(options) {
313
316
  const selection = this.getSelection();
314
317
  // Delete all selected nodes (this will also remove connected edges)
315
318
  for (const nodeId of selection.nodes) {
@@ -320,14 +323,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
320
323
  this.disconnect(edgeId);
321
324
  }
322
325
  // Clear selection
323
- this.setSelection({ nodes: [], edges: [] });
326
+ this.setSelection({ nodes: [], edges: [] }, options);
324
327
  }
325
- setViewport(viewport, opts) {
328
+ setViewport(viewport, options) {
326
329
  this.viewport = { ...viewport };
327
330
  this.emit("graphUiChanged", {
328
331
  def: this.def,
329
332
  change: { type: "viewport" },
330
- commit: opts?.commit,
333
+ ...options,
331
334
  });
332
335
  }
333
336
  getViewport() {
@@ -472,7 +475,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
472
475
  * Returns the mapping from original node IDs to new node IDs.
473
476
  * Uses copyOutputsFrom to copy outputs from original nodes (like duplicate does).
474
477
  */
475
- pasteCopiedData(data, center) {
478
+ pasteCopiedData(data, center, options) {
476
479
  const nodeIdMap = new Map();
477
480
  const edgeIds = [];
478
481
  // Add nodes
@@ -512,10 +515,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
512
515
  }
513
516
  }
514
517
  // Select the newly pasted nodes
515
- this.setSelection({
516
- nodes: Array.from(nodeIdMap.values()),
517
- edges: edgeIds,
518
- });
518
+ this.setSelection({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
519
519
  return { nodeIdMap, edgeIds };
520
520
  }
521
521
  /**
@@ -684,7 +684,7 @@ class AbstractGraphRunner {
684
684
  return false;
685
685
  }
686
686
  // Optional commit support
687
- async commit() { }
687
+ async commit(_reason) { }
688
688
  }
689
689
 
690
690
  // Counter for generating readable runner IDs
@@ -1431,10 +1431,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1431
1431
  const client = await this.ensureClient();
1432
1432
  await client.setExtData(data);
1433
1433
  }
1434
- async commit() {
1434
+ async commit(reason) {
1435
1435
  const client = await this.ensureClient();
1436
1436
  try {
1437
- await client.commit();
1437
+ await client.commit(reason);
1438
1438
  }
1439
1439
  catch (err) {
1440
1440
  console.error("[RemoteGraphRunner] Error committing:", err);
@@ -1914,7 +1914,7 @@ function useWorkbenchBridge(wb) {
1914
1914
  wb.connect({
1915
1915
  source: { nodeId: params.source, handle: params.sourceHandle },
1916
1916
  target: { nodeId: params.target, handle: params.targetHandle },
1917
- });
1917
+ }, { commit: true });
1918
1918
  }, [wb]);
1919
1919
  const onNodesChange = useCallback((changes) => {
1920
1920
  // Apply position updates continuously, but mark commit only on drag end
@@ -1957,7 +1957,7 @@ function useWorkbenchBridge(wb) {
1957
1957
  });
1958
1958
  }
1959
1959
  }, [wb]);
1960
- const onEdgesDelete = useCallback((edges) => edges.forEach((e) => wb.disconnect(e.id)), [wb]);
1960
+ const onEdgesDelete = useCallback((edges) => edges.forEach((e, idx) => wb.disconnect(e.id, { commit: idx === edges.length - 1 })), [wb]);
1961
1961
  const onEdgesChange = useCallback((changes) => {
1962
1962
  const current = wb.getSelection();
1963
1963
  const nextEdgeIds = new Set(current.edges);
@@ -1993,8 +1993,7 @@ function useWorkbenchBridge(wb) {
1993
1993
  }
1994
1994
  }, [wb]);
1995
1995
  const onNodesDelete = useCallback((nodes) => {
1996
- for (const n of nodes)
1997
- wb.removeNode(n.id);
1996
+ nodes.forEach((n, idx) => wb.removeNode(n.id, { commit: idx === nodes.length - 1 }));
1998
1997
  }, [wb]);
1999
1998
  return {
2000
1999
  onConnect,
@@ -2687,7 +2686,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2687
2686
  }
2688
2687
  curX += maxWidth + H_GAP;
2689
2688
  }
2690
- wb.setPositions(pos, { commit: true });
2689
+ wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2691
2690
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2692
2691
  const updateEdgeType = useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2693
2692
  const triggerExternal = useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -2943,11 +2942,36 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2943
2942
  }
2944
2943
  });
2945
2944
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
2945
+ // Build detailed reason from change type
2946
+ let reason = "graph-changed";
2947
+ if (event.change) {
2948
+ const changeType = event.change.type;
2949
+ if (changeType === "addNode") {
2950
+ reason = "add-node";
2951
+ }
2952
+ else if (changeType === "removeNode") {
2953
+ reason = "remove-node";
2954
+ }
2955
+ else if (changeType === "connect") {
2956
+ reason = "connect-edge";
2957
+ }
2958
+ else if (changeType === "disconnect") {
2959
+ reason = "disconnect-edge";
2960
+ }
2961
+ else if (changeType === "updateParams") {
2962
+ reason = "update-node-params";
2963
+ }
2964
+ else if (changeType === "updateEdgeType") {
2965
+ reason = "update-edge-type";
2966
+ }
2967
+ }
2946
2968
  if (!runner.isRunning()) {
2947
- // If runner not running, commit immediately (no update needed)
2948
- await runner.commit().catch((err) => {
2949
- console.error("[WorkbenchContext] Error committing:", err);
2950
- });
2969
+ if (event.commit) {
2970
+ // If runner not running, commit immediately (no update needed)
2971
+ await runner.commit(reason).catch((err) => {
2972
+ console.error("[WorkbenchContext] Error committing:", err);
2973
+ });
2974
+ }
2951
2975
  return;
2952
2976
  }
2953
2977
  try {
@@ -2972,10 +2996,12 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2972
2996
  else {
2973
2997
  await runner.update(event.def, { dry: event.dry });
2974
2998
  }
2975
- // Wait for update to complete, then commit
2976
- await runner.commit().catch((err) => {
2977
- console.error("[WorkbenchContext] Error committing after update:", err);
2978
- });
2999
+ if (event.commit) {
3000
+ // Wait for update to complete, then commit
3001
+ await runner.commit(event.reason ?? reason).catch((err) => {
3002
+ console.error("[WorkbenchContext] Error committing after update:", err);
3003
+ });
3004
+ }
2979
3005
  }
2980
3006
  catch (err) {
2981
3007
  console.error("[WorkbenchContext] Error updating graph:", err);
@@ -2985,15 +3011,22 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2985
3011
  const offWbSelectionChanged = wb.on("selectionChanged", async (sel) => {
2986
3012
  setSelectedNodeId(sel.nodes?.[0]);
2987
3013
  setSelectedEdgeId(sel.edges?.[0]);
2988
- // Commit on selection change
2989
- await runner.commit().catch((err) => {
2990
- console.error("[WorkbenchContext] Error committing selection change:", err);
2991
- });
3014
+ if (sel.commit) {
3015
+ // Commit on selection change
3016
+ await runner.commit(sel.reason ?? "selection").catch((err) => {
3017
+ console.error("[WorkbenchContext] Error committing selection change:", err);
3018
+ });
3019
+ }
2992
3020
  });
2993
3021
  const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
2994
3022
  // Only commit if commit flag is true (e.g., drag end, not during dragging)
2995
3023
  if (event.commit) {
2996
- await runner.commit().catch((err) => {
3024
+ if (event.change) {
3025
+ event.change.type;
3026
+ }
3027
+ await runner
3028
+ .commit(event.reason ?? "ui-changed")
3029
+ .catch((err) => {
2997
3030
  console.error("[WorkbenchContext] Error committing UI changes:", err);
2998
3031
  });
2999
3032
  }
@@ -3209,6 +3242,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3209
3242
  try {
3210
3243
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
3211
3244
  const raw = outputsMap?.[nodeId]?.[handleId];
3245
+ let newNodeId;
3212
3246
  if (!typeId || raw === undefined)
3213
3247
  return;
3214
3248
  const unwrap = (v) => isTypedOutput(v) ? getTypedOutputValue(v) : v;
@@ -3234,23 +3268,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3234
3268
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3235
3269
  const inType = getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3236
3270
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3237
- wb.addNode({
3271
+ newNodeId = wb.addNode({
3238
3272
  typeId: singleTarget.nodeTypeId,
3239
3273
  position: { x: pos.x + 180, y: pos.y },
3240
3274
  }, { inputs: { [singleTarget.inputHandle]: coerced } });
3241
- return;
3242
3275
  }
3243
- if (isArray && arrTarget) {
3276
+ else if (isArray && arrTarget) {
3244
3277
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3245
3278
  const inType = getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3246
3279
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3247
- wb.addNode({
3280
+ newNodeId = wb.addNode({
3248
3281
  typeId: arrTarget.nodeTypeId,
3249
3282
  position: { x: pos.x + 180, y: pos.y },
3250
3283
  }, { inputs: { [arrTarget.inputHandle]: coerced } });
3251
- return;
3252
3284
  }
3253
- if (isArray && elemTarget) {
3285
+ else if (isArray && elemTarget) {
3254
3286
  const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3255
3287
  const inType = getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3256
3288
  const src = unwrap(raw);
@@ -3262,19 +3294,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3262
3294
  for (let idx = 0; idx < coercedItems.length; idx++) {
3263
3295
  const col = idx % COLS;
3264
3296
  const row = Math.floor(idx / COLS);
3265
- wb.addNode({
3297
+ newNodeId = wb.addNode({
3266
3298
  typeId: elemTarget.nodeTypeId,
3267
3299
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3268
3300
  }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3269
3301
  }
3270
- return;
3302
+ }
3303
+ if (newNodeId) {
3304
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3271
3305
  }
3272
3306
  }
3273
3307
  catch { }
3274
3308
  };
3275
3309
  return {
3276
3310
  onDelete: () => {
3277
- wb.removeNode(nodeId);
3311
+ wb.removeNode(nodeId, { commit: true });
3278
3312
  onClose();
3279
3313
  },
3280
3314
  onDuplicate: async () => {
@@ -3299,10 +3333,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3299
3333
  dry: true,
3300
3334
  });
3301
3335
  // Select the newly duplicated node
3302
- wb.setSelection({
3303
- nodes: [newNodeId],
3304
- edges: [],
3305
- });
3336
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3306
3337
  onClose();
3307
3338
  },
3308
3339
  onDuplicateWithEdges: async () => {
@@ -3335,10 +3366,9 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3335
3366
  }, { dry: true });
3336
3367
  }
3337
3368
  // Select the newly duplicated node and edges
3338
- wb.setSelection({
3339
- nodes: [newNodeId],
3340
- edges: [],
3341
- });
3369
+ if (newNodeId) {
3370
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3371
+ }
3342
3372
  onClose();
3343
3373
  },
3344
3374
  onRunPull: async () => {
@@ -3412,7 +3442,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3412
3442
  onClose();
3413
3443
  },
3414
3444
  onDelete: () => {
3415
- wb.deleteSelection();
3445
+ wb.deleteSelection({ commit: true, reason: "delete-selection" });
3416
3446
  onClose();
3417
3447
  },
3418
3448
  onClose,
@@ -4094,10 +4124,14 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4094
4124
  }, children: [hasPasteData && handlers.onPaste && (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: [jsx("span", { children: "Paste" }), enableKeyboardShortcuts && keyboardShortcuts.paste && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.paste) }))] })), (handlers.onUndo || handlers.onRedo) && (jsxs(Fragment, { children: [hasPasteData && handlers.onPaste && (jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (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: [jsx("span", { children: "Undo" }), enableKeyboardShortcuts && keyboardShortcuts.undo && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.undo) }))] })), handlers.onRedo && (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: [jsx("span", { children: "Redo" }), enableKeyboardShortcuts && keyboardShortcuts.redo && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.redo) }))] }))] })), hasPasteData &&
4095
4125
  handlers.onPaste &&
4096
4126
  !handlers.onUndo &&
4097
- !handlers.onRedo && jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsx("div", { className: "px-2 pb-1", children: 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() }) }), jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
4127
+ !handlers.onRedo && jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsx("div", { className: "px-2 pb-1", children: 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() }) }), jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
4098
4128
  }
4099
4129
 
4100
- function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
4130
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, enableKeyboardShortcuts = true, keyboardShortcuts = {
4131
+ copy: "⌘/Ctrl + C",
4132
+ duplicate: "⌘/Ctrl + D",
4133
+ delete: "Delete",
4134
+ }, }) {
4101
4135
  const ref = useRef(null);
4102
4136
  // outside click + ESC
4103
4137
  useEffect(() => {
@@ -4124,6 +4158,12 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4124
4158
  if (open)
4125
4159
  ref.current?.focus();
4126
4160
  }, [open]);
4161
+ // Helper to format shortcut for current platform
4162
+ const formatShortcut = (shortcut) => {
4163
+ const isMac = typeof navigator !== "undefined" &&
4164
+ navigator.userAgent.toLowerCase().includes("mac");
4165
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4166
+ };
4127
4167
  if (!open || !clientPos || !nodeId)
4128
4168
  return null;
4129
4169
  // clamp
@@ -4135,7 +4175,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4135
4175
  return (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) => {
4136
4176
  e.preventDefault();
4137
4177
  e.stopPropagation();
4138
- }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopy, children: "Copy" }), 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 && (jsxs(Fragment, { children: [jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4178
+ }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), 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: [jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] }), 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: [jsx("span", { children: "Duplicate" }), enableKeyboardShortcuts && keyboardShortcuts.duplicate && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.duplicate) }))] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), 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: [jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), 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 && (jsxs(Fragment, { children: [jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4139
4179
  }
4140
4180
 
4141
4181
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -4523,7 +4563,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4523
4563
  setNodeMenuOpen(false);
4524
4564
  setSelectionMenuOpen(false);
4525
4565
  };
4526
- const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs }), [wb]);
4566
+ const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs, commit: true }), [wb]);
4527
4567
  const onCloseMenu = useCallback(() => {
4528
4568
  setMenuOpen(false);
4529
4569
  }, []);
@@ -4555,7 +4595,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4555
4595
  const data = storage.get();
4556
4596
  if (!data)
4557
4597
  return;
4558
- wb.pasteCopiedData(data, position);
4598
+ wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4559
4599
  onCloseMenu();
4560
4600
  }, runner, () => storage.get(), () => storage.set(null));
4561
4601
  if (overrides?.getDefaultContextMenuHandlers) {
@@ -4576,9 +4616,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4576
4616
  }, runner);
4577
4617
  if (overrides?.getSelectionContextMenuHandlers) {
4578
4618
  const selection = wb.getSelection();
4579
- return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, {
4580
- getDefaultNodeSize: overrides.getDefaultNodeSize,
4581
- });
4619
+ return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, { getDefaultNodeSize: overrides.getDefaultNodeSize });
4582
4620
  }
4583
4621
  return baseHandlers;
4584
4622
  }, [wb, runner, overrides, onCloseSelectionMenu]);
@@ -4624,6 +4662,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4624
4662
  redo: "⌘/Ctrl + Shift + Z",
4625
4663
  copy: "⌘/Ctrl + C",
4626
4664
  paste: "⌘/Ctrl + V",
4665
+ duplicate: "⌘/Ctrl + D",
4627
4666
  delete: "Delete",
4628
4667
  };
4629
4668
  // Keyboard shortcut handler
@@ -4674,12 +4713,26 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4674
4713
  const selection = wb.getSelection();
4675
4714
  if (selection.nodes.length > 0 || selection.edges.length > 0) {
4676
4715
  e.preventDefault();
4677
- if (selectionContextMenuHandlers.onCopy) {
4716
+ // If single node selected, use node context menu handler; otherwise use selection handler
4717
+ if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
4718
+ nodeContextMenuHandlers.onCopy();
4719
+ }
4720
+ else if (selectionContextMenuHandlers.onCopy) {
4678
4721
  selectionContextMenuHandlers.onCopy();
4679
4722
  }
4680
4723
  }
4681
4724
  return;
4682
4725
  }
4726
+ // Duplicate: Cmd/Ctrl + D
4727
+ if (modKey && key === "d" && !e.shiftKey && !e.altKey) {
4728
+ const selection = wb.getSelection();
4729
+ if (selection.nodes.length === 1 &&
4730
+ nodeContextMenuHandlers?.onDuplicate) {
4731
+ e.preventDefault();
4732
+ nodeContextMenuHandlers.onDuplicate();
4733
+ }
4734
+ return;
4735
+ }
4683
4736
  // Paste: Cmd/Ctrl + V
4684
4737
  if (modKey && key === "v" && !e.shiftKey && !e.altKey) {
4685
4738
  e.preventDefault();
@@ -4709,6 +4762,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4709
4762
  runner,
4710
4763
  defaultContextMenuHandlers,
4711
4764
  selectionContextMenuHandlers,
4765
+ nodeContextMenuHandlers,
4712
4766
  rfInstanceRef,
4713
4767
  ]);
4714
4768
  // Get custom renderers from UI extension registry (reactive to uiVersion changes)
@@ -4725,7 +4779,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4725
4779
  const onMoveEnd = useCallback(() => {
4726
4780
  if (rfInstanceRef.current) {
4727
4781
  const viewport = rfInstanceRef.current.getViewport();
4728
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
4782
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
4729
4783
  }
4730
4784
  }, [wb]);
4731
4785
  const viewportRef = useRef(null);
@@ -4761,7 +4815,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4761
4815
  ? { enableKeyboardShortcuts, keyboardShortcuts }
4762
4816
  : {}) })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
4763
4817
  nodeContextMenuHandlers &&
4764
- (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs }))), selectionMenuOpen && selectionMenuPos && (jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
4818
+ (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, ...(enableKeyboardShortcuts !== false
4819
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4820
+ : {}) })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))), selectionMenuOpen && selectionMenuPos && (jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
4765
4821
  });
4766
4822
 
4767
4823
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {