@bian-womp/spark-workbench 0.2.53 → 0.2.55

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 (65) hide show
  1. package/lib/cjs/index.cjs +383 -292
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/AbstractWorkbench.d.ts +2 -0
  4. package/lib/cjs/src/core/AbstractWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +9 -2
  6. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  7. package/lib/cjs/src/core/contracts.d.ts +3 -0
  8. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  9. package/lib/cjs/src/core/ui-extensions.d.ts +44 -47
  10. package/lib/cjs/src/core/ui-extensions.d.ts.map +1 -1
  11. package/lib/cjs/src/index.d.ts +1 -0
  12. package/lib/cjs/src/index.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/DefaultContextMenu.d.ts +2 -12
  14. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/NodeContextMenu.d.ts +2 -9
  16. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  17. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  19. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +41 -0
  20. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -0
  21. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts +7 -0
  22. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -0
  23. package/lib/cjs/src/misc/load.d.ts +5 -0
  24. package/lib/cjs/src/misc/load.d.ts.map +1 -0
  25. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +3 -2
  26. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  27. package/lib/cjs/src/runtime/IGraphRunner.d.ts +1 -0
  28. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  29. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +1 -1
  30. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  31. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +0 -1
  32. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  33. package/lib/esm/index.js +384 -295
  34. package/lib/esm/index.js.map +1 -1
  35. package/lib/esm/src/core/AbstractWorkbench.d.ts +2 -0
  36. package/lib/esm/src/core/AbstractWorkbench.d.ts.map +1 -1
  37. package/lib/esm/src/core/InMemoryWorkbench.d.ts +9 -2
  38. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  39. package/lib/esm/src/core/contracts.d.ts +3 -0
  40. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  41. package/lib/esm/src/core/ui-extensions.d.ts +44 -47
  42. package/lib/esm/src/core/ui-extensions.d.ts.map +1 -1
  43. package/lib/esm/src/index.d.ts +1 -0
  44. package/lib/esm/src/index.d.ts.map +1 -1
  45. package/lib/esm/src/misc/DefaultContextMenu.d.ts +2 -12
  46. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  47. package/lib/esm/src/misc/NodeContextMenu.d.ts +2 -9
  48. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  49. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  50. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  51. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +41 -0
  52. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -0
  53. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts +7 -0
  54. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -0
  55. package/lib/esm/src/misc/load.d.ts +5 -0
  56. package/lib/esm/src/misc/load.d.ts.map +1 -0
  57. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +3 -2
  58. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  59. package/lib/esm/src/runtime/IGraphRunner.d.ts +1 -0
  60. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  61. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +1 -1
  62. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  63. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +0 -1
  64. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  65. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -12,71 +12,75 @@ var isEqual = require('lodash/isEqual');
12
12
  class DefaultUIExtensionRegistry {
13
13
  constructor() {
14
14
  this.nodeRenderers = new Map();
15
- this.portRenderers = new Map();
16
- this.edgeRenderers = new Map();
17
15
  }
18
16
  registerNodeRenderer(nodeTypeId, renderer) {
19
- this.nodeRenderers.set(nodeTypeId, renderer);
17
+ if (renderer === undefined) {
18
+ this.nodeRenderers.delete(nodeTypeId);
19
+ }
20
+ else {
21
+ this.nodeRenderers.set(nodeTypeId, renderer);
22
+ }
20
23
  return this;
21
24
  }
22
25
  getNodeRenderer(nodeTypeId) {
23
26
  return this.nodeRenderers.get(nodeTypeId);
24
27
  }
25
- registerPortRenderer(dataTypeId, renderer) {
26
- this.portRenderers.set(dataTypeId, renderer);
27
- return this;
28
- }
29
- getPortRenderer(dataTypeId) {
30
- return this.portRenderers.get(dataTypeId);
28
+ getAllNodeRenderers() {
29
+ const result = {};
30
+ for (const [nodeTypeId, renderer] of this.nodeRenderers.entries()) {
31
+ result[nodeTypeId] = renderer;
32
+ }
33
+ return result;
31
34
  }
32
- registerEdgeRenderer(typeId, renderer) {
33
- this.edgeRenderers.set(typeId, renderer);
35
+ registerIconProvider(provider) {
36
+ this.iconProvider = provider;
34
37
  return this;
35
38
  }
36
- getEdgeRenderer(typeId) {
37
- return this.edgeRenderers.get(typeId);
39
+ getIconProvider() {
40
+ return this.iconProvider;
38
41
  }
39
- setInspector(renderer) {
40
- this.inspector = renderer;
42
+ // React Flow renderers
43
+ registerConnectionLineRenderer(renderer) {
44
+ this.connectionLineRenderer = renderer;
41
45
  return this;
42
46
  }
43
- getInspector() {
44
- return this.inspector;
47
+ getConnectionLineRenderer() {
48
+ return this.connectionLineRenderer;
45
49
  }
46
- setPalette(renderer) {
47
- this.palette = renderer;
50
+ registerMinimapRenderer(renderer) {
51
+ this.minimapRenderer = renderer;
48
52
  return this;
49
53
  }
50
- getPalette() {
51
- return this.palette;
54
+ getMinimapRenderer() {
55
+ return this.minimapRenderer;
52
56
  }
53
- setToolbar(renderer) {
54
- this.toolbar = renderer;
57
+ registerControlsRenderer(renderer) {
58
+ this.controlsRenderer = renderer;
55
59
  return this;
56
60
  }
57
- getToolbar() {
58
- return this.toolbar;
61
+ getControlsRenderer() {
62
+ return this.controlsRenderer;
59
63
  }
60
- setContextMenu(renderer) {
61
- this.contextMenu = renderer;
64
+ registerBackgroundRenderer(renderer) {
65
+ this.backgroundRenderer = renderer;
62
66
  return this;
63
67
  }
64
- getContextMenu() {
65
- return this.contextMenu;
68
+ getBackgroundRenderer() {
69
+ return this.backgroundRenderer;
66
70
  }
67
- setMiniMap(renderer) {
68
- this.miniMap = renderer;
71
+ registerDefaultContextMenuRenderer(renderer) {
72
+ this.defaultContextMenuRenderer = renderer;
69
73
  return this;
70
74
  }
71
- getMiniMap() {
72
- return this.miniMap;
75
+ getDefaultContextMenuRenderer() {
76
+ return this.defaultContextMenuRenderer;
73
77
  }
74
- setIconProvider(provider) {
75
- this.iconProvider = provider;
78
+ registerNodeContextMenuRenderer(renderer) {
79
+ this.nodeContextMenuRenderer = renderer;
76
80
  return this;
77
81
  }
78
- getIconProvider() {
79
- return this.iconProvider;
82
+ getNodeContextMenuRenderer() {
83
+ return this.nodeContextMenuRenderer;
80
84
  }
81
85
  }
82
86
 
@@ -86,6 +90,7 @@ class AbstractWorkbench {
86
90
  this.layout = args.layout;
87
91
  this.storage = args.storage;
88
92
  this.serializer = args.serializer;
93
+ this.genId = args.genId || sparkGraph.generateId;
89
94
  }
90
95
  // Expose UI registry to adapters (React Flow, CLI) to allow overrides
91
96
  getUI() {
@@ -114,6 +119,13 @@ class InMemoryWorkbench extends AbstractWorkbench {
114
119
  const { positions } = await this.layout.layout(this.def);
115
120
  this.positions = positions;
116
121
  }
122
+ const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
123
+ const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
124
+ const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
125
+ const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
126
+ const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
127
+ this.positions = filteredPositions;
128
+ this.selection = { nodes: filteredNodes, edges: filteredEdges };
117
129
  this.emit("graphChanged", { def: this.def });
118
130
  this.refreshValidation();
119
131
  }
@@ -157,11 +169,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
157
169
  return { ok: issues.every((i) => i.level !== "error"), issues };
158
170
  }
159
171
  addNode(node) {
160
- const id = node.nodeId ?? this.generateId("n");
172
+ const id = node.nodeId ??
173
+ this.genId("n", new Set(this.def.nodes.map((n) => n.nodeId)));
161
174
  this.def.nodes.push({
162
175
  nodeId: id,
163
176
  typeId: node.typeId,
164
177
  params: node.params,
178
+ initialInputs: node.initialInputs,
179
+ resolvedHandles: node.resolvedHandles,
165
180
  });
166
181
  if (node.position)
167
182
  this.positions[id] = node.position;
@@ -183,7 +198,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
183
198
  this.refreshValidation();
184
199
  }
185
200
  connect(edge) {
186
- const id = edge.id ?? this.generateId("e");
201
+ const id = edge.id ?? this.genId("e", new Set(this.def.edges.map((e) => e.id)));
187
202
  this.def.edges.push({
188
203
  id,
189
204
  source: { ...edge.source },
@@ -230,29 +245,32 @@ class InMemoryWorkbench extends AbstractWorkbench {
230
245
  });
231
246
  }
232
247
  // Position and selection APIs for React Flow bridge
233
- setPosition(nodeId, pos) {
248
+ setPosition(nodeId, pos, opts) {
234
249
  this.positions[nodeId] = pos;
235
250
  this.emit("graphUiChanged", {
236
251
  def: this.def,
237
252
  change: { type: "moveNode", nodeId, pos },
253
+ commit: !!opts?.commit === true,
238
254
  });
239
255
  }
240
- setPositions(map) {
256
+ setPositions(map, opts) {
241
257
  this.positions = { ...map };
242
258
  this.emit("graphUiChanged", {
243
259
  def: this.def,
244
260
  change: { type: "moveNodes" },
261
+ commit: opts?.commit,
245
262
  });
246
263
  }
247
264
  getPositions() {
248
265
  return { ...this.positions };
249
266
  }
250
- setSelection(sel) {
267
+ setSelection(sel, opts) {
251
268
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
252
269
  this.emit("selectionChanged", this.selection);
253
270
  this.emit("graphUiChanged", {
254
271
  def: this.def,
255
272
  change: { type: "selection" },
273
+ commit: opts?.commit,
256
274
  });
257
275
  }
258
276
  getSelection() {
@@ -261,21 +279,31 @@ class InMemoryWorkbench extends AbstractWorkbench {
261
279
  edges: [...this.selection.edges],
262
280
  };
263
281
  }
264
- setViewport(viewport) {
282
+ setViewport(viewport, opts) {
265
283
  this.viewport = { ...viewport };
284
+ this.emit("graphUiChanged", {
285
+ def: this.def,
286
+ change: { type: "viewport" },
287
+ commit: opts?.commit,
288
+ });
266
289
  }
267
290
  getViewport() {
268
291
  return this.viewport ? { ...this.viewport } : null;
269
292
  }
270
293
  getUIState() {
294
+ const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
295
+ const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
296
+ const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
297
+ const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
298
+ const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
271
299
  return {
272
- positions: Object.keys(this.positions).length > 0
273
- ? { ...this.positions }
300
+ positions: Object.keys(filteredPositions).length > 0
301
+ ? filteredPositions
274
302
  : undefined,
275
- selection: this.selection.nodes.length > 0 || this.selection.edges.length > 0
303
+ selection: filteredNodes.length > 0 || filteredEdges.length > 0
276
304
  ? {
277
- nodes: [...this.selection.nodes],
278
- edges: [...this.selection.edges],
305
+ nodes: filteredNodes,
306
+ edges: filteredEdges,
279
307
  }
280
308
  : undefined,
281
309
  viewport: this.viewport ? { ...this.viewport } : undefined,
@@ -311,9 +339,6 @@ class InMemoryWorkbench extends AbstractWorkbench {
311
339
  for (const h of Array.from(set))
312
340
  h(payload);
313
341
  }
314
- generateId(prefix) {
315
- return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
316
- }
317
342
  }
318
343
 
319
344
  class CLIWorkbench {
@@ -445,6 +470,18 @@ class AbstractGraphRunner {
445
470
  }
446
471
  }
447
472
  }
473
+ getInputDefaults(def) {
474
+ const out = {};
475
+ for (const n of def.nodes) {
476
+ const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
477
+ const graphDefaults = n.initialInputs ?? {};
478
+ const merged = { ...dynDefaults, ...graphDefaults };
479
+ if (Object.keys(merged).length > 0) {
480
+ out[n.nodeId] = merged;
481
+ }
482
+ }
483
+ return out;
484
+ }
448
485
  on(event, handler) {
449
486
  if (!this.listeners.has(event))
450
487
  this.listeners.set(event, new Set());
@@ -606,16 +643,6 @@ class LocalGraphRunner extends AbstractGraphRunner {
606
643
  }
607
644
  return out;
608
645
  }
609
- getInputDefaults(def) {
610
- const out = {};
611
- for (const n of def.nodes) {
612
- const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
613
- if (Object.keys(dynDefaults).length > 0) {
614
- out[n.nodeId] = dynDefaults;
615
- }
616
- }
617
- return out;
618
- }
619
646
  async snapshotFull() {
620
647
  const def = undefined; // UI will supply def/positions on download for local
621
648
  const inputs = this.getInputs(this.runtime
@@ -643,11 +670,25 @@ class LocalGraphRunner extends AbstractGraphRunner {
643
670
  if (payload.def)
644
671
  this.build(payload.def);
645
672
  this.setEnvironment?.(payload.environment || {}, { merge: false });
646
- // Hydrate via runtime for exact restore and re-emit
673
+ this.hydrateSnapshotFull(payload);
674
+ }
675
+ hydrateSnapshotFull(snapshot) {
676
+ // Hydrate via runtime for exact restore (this emits events on runtime emitter)
647
677
  this.runtime?.hydrate({
648
- inputs: payload.inputs || {},
649
- outputs: payload.outputs || {},
678
+ inputs: snapshot.inputs || {},
679
+ outputs: snapshot.outputs || {},
650
680
  });
681
+ // Also emit directly from runner to ensure UI gets events even if engine isn't running
682
+ for (const [nodeId, map] of Object.entries(snapshot.inputs || {})) {
683
+ for (const [handle, value] of Object.entries(map || {})) {
684
+ this.emit("value", { nodeId, handle, value, io: "input" });
685
+ }
686
+ }
687
+ for (const [nodeId, map] of Object.entries(snapshot.outputs || {})) {
688
+ for (const [handle, value] of Object.entries(map || {})) {
689
+ this.emit("value", { nodeId, handle, value, io: "output" });
690
+ }
691
+ }
651
692
  }
652
693
  dispose() {
653
694
  super.dispose();
@@ -842,10 +883,13 @@ class RemoteGraphRunner extends AbstractGraphRunner {
842
883
  // Auto-fetch registry on first connection (only once)
843
884
  if (!this.registryFetched && !this.registryFetching) {
844
885
  // Log loading state (UI can listen to transport status for loading indication)
845
- console.info("Loading registry from remote...");
846
- this.fetchRegistry(client).catch((err) => {
886
+ console.info("[RemoteGraphRunner] Loading registry from remote...");
887
+ this.fetchRegistry(client)
888
+ .then(() => {
889
+ console.info("[RemoteGraphRunner] Loaded registry from remote");
890
+ })
891
+ .catch((err) => {
847
892
  console.error("[RemoteGraphRunner] Failed to fetch registry:", err);
848
- // Error handling is done inside fetchRegistry, but we catch unhandled rejections
849
893
  });
850
894
  }
851
895
  // Clear promise on success
@@ -1201,16 +1245,6 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1201
1245
  }
1202
1246
  return out;
1203
1247
  }
1204
- getInputDefaults(def) {
1205
- const out = {};
1206
- for (const n of def.nodes) {
1207
- const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
1208
- if (Object.keys(dynDefaults).length > 0) {
1209
- out[n.nodeId] = dynDefaults;
1210
- }
1211
- }
1212
- return out;
1213
- }
1214
1248
  dispose() {
1215
1249
  // Idempotent: allow multiple calls safely
1216
1250
  if (this.disposed)
@@ -1479,10 +1513,10 @@ function useWorkbenchBridge(wb) {
1479
1513
  });
1480
1514
  }, [wb]);
1481
1515
  const onNodesChange = React.useCallback((changes) => {
1482
- // Apply position updates
1516
+ // Apply position updates continuously, but mark commit only on drag end
1483
1517
  changes.forEach((c) => {
1484
1518
  if (c.type === "position" && c.position) {
1485
- wb.setPosition(c.id, c.position);
1519
+ wb.setPosition(c.id, c.position, { commit: !c.dragging });
1486
1520
  }
1487
1521
  });
1488
1522
  // Derive next node selection from change set
@@ -1956,6 +1990,99 @@ function getHandleClassName(args) {
1956
1990
  return cx("!w-3 !h-3 !bg-white !dark:bg-stone-900", borderColor, kind === "output" && "!rounded-none");
1957
1991
  }
1958
1992
 
1993
+ function generateTimestamp() {
1994
+ const d = new Date();
1995
+ const pad = (n) => String(n).padStart(2, "0");
1996
+ return `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
1997
+ }
1998
+ function downloadJSON(payload, filename) {
1999
+ const pretty = JSON.stringify(payload, null, 2);
2000
+ const blob = new Blob([pretty], { type: "application/json" });
2001
+ const url = URL.createObjectURL(blob);
2002
+ const a = document.createElement("a");
2003
+ a.href = url;
2004
+ a.download = filename;
2005
+ document.body.appendChild(a);
2006
+ a.click();
2007
+ a.remove();
2008
+ URL.revokeObjectURL(url);
2009
+ }
2010
+ function isSnapshotPayload(parsed) {
2011
+ return (parsed !== null &&
2012
+ typeof parsed === "object" &&
2013
+ ("def" in parsed ||
2014
+ "inputs" in parsed ||
2015
+ "outputs" in parsed ||
2016
+ "environment" in parsed));
2017
+ }
2018
+ async function download(wb, runner) {
2019
+ try {
2020
+ const def = wb.export();
2021
+ const uiState = wb.getUIState();
2022
+ let snapshot;
2023
+ if (runner.isRunning()) {
2024
+ const fullSnapshot = await runner.snapshotFull();
2025
+ snapshot = {
2026
+ ...fullSnapshot,
2027
+ def,
2028
+ extData: {
2029
+ ...(fullSnapshot.extData || {}),
2030
+ ui: uiState,
2031
+ },
2032
+ };
2033
+ }
2034
+ else {
2035
+ const inputs = runner.getInputs(def);
2036
+ snapshot = {
2037
+ def,
2038
+ inputs,
2039
+ outputs: {},
2040
+ environment: {},
2041
+ extData: { ui: uiState },
2042
+ };
2043
+ }
2044
+ downloadJSON(snapshot, `spark-snapshot-${generateTimestamp()}.json`);
2045
+ }
2046
+ catch (err) {
2047
+ const message = err instanceof Error ? err.message : String(err);
2048
+ throw new Error(`Failed to download snapshot: ${message}`);
2049
+ }
2050
+ }
2051
+ async function upload(parsed, wb, runner) {
2052
+ if (!isSnapshotPayload(parsed)) {
2053
+ throw new Error("Invalid snapshot format - expected RuntimeSnapshotFull");
2054
+ }
2055
+ const def = parsed.def;
2056
+ const environment = parsed.environment || {};
2057
+ const inputs = parsed.inputs || {};
2058
+ const outputs = parsed.outputs || {};
2059
+ const extData = parsed.extData || {};
2060
+ if (!def) {
2061
+ throw new Error("Graph definition is empty");
2062
+ }
2063
+ await wb.load(def);
2064
+ if (extData.ui && typeof extData.ui === "object") {
2065
+ wb.setUIState(extData.ui);
2066
+ }
2067
+ if (runner.isRunning()) {
2068
+ await runner.applySnapshotFull({
2069
+ def,
2070
+ environment,
2071
+ inputs,
2072
+ outputs,
2073
+ extData,
2074
+ });
2075
+ }
2076
+ else {
2077
+ runner.build(wb.export());
2078
+ if (inputs && typeof inputs === "object") {
2079
+ for (const [nodeId, map] of Object.entries(inputs)) {
2080
+ runner.setInputs(nodeId, map);
2081
+ }
2082
+ }
2083
+ }
2084
+ }
2085
+
1959
2086
  const WorkbenchContext = React.createContext(null);
1960
2087
  function useWorkbenchContext() {
1961
2088
  const ctx = React.useContext(WorkbenchContext);
@@ -2141,7 +2268,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2141
2268
  }
2142
2269
  curX += maxWidth + H_GAP;
2143
2270
  }
2144
- wb.setPositions(pos);
2271
+ wb.setPositions(pos, { commit: true });
2145
2272
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2146
2273
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2147
2274
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -3098,15 +3225,13 @@ function DefaultNodeContent({ data, isConnectable, }) {
3098
3225
  } })] }));
3099
3226
  }
3100
3227
 
3101
- function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3102
- const { registry } = useWorkbenchContext();
3228
+ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3103
3229
  const rf = react.useReactFlow();
3104
- const ids = Array.from(registry.nodes.keys());
3105
3230
  const [query, setQuery] = React.useState("");
3106
3231
  const q = query.trim().toLowerCase();
3107
3232
  const filteredIds = q
3108
- ? ids.filter((id) => id.toLowerCase().includes(q))
3109
- : ids;
3233
+ ? nodeIds.filter((id) => id.toLowerCase().includes(q))
3234
+ : nodeIds;
3110
3235
  const root = { __children: {} };
3111
3236
  for (const id of filteredIds) {
3112
3237
  const parts = id.split(".");
@@ -3130,11 +3255,11 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3130
3255
  if (!ref.current)
3131
3256
  return;
3132
3257
  if (!ref.current.contains(e.target))
3133
- onClose();
3258
+ handlers.onClose();
3134
3259
  };
3135
3260
  const onKey = (e) => {
3136
3261
  if (e.key === "Escape")
3137
- onClose();
3262
+ handlers.onClose();
3138
3263
  };
3139
3264
  window.addEventListener("mousedown", onDown, true);
3140
3265
  window.addEventListener("keydown", onKey);
@@ -3142,7 +3267,7 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3142
3267
  window.removeEventListener("mousedown", onDown, true);
3143
3268
  window.removeEventListener("keydown", onKey);
3144
3269
  };
3145
- }, [open, onClose]);
3270
+ }, [open, handlers]);
3146
3271
  // Focus search input when menu opens
3147
3272
  const inputRef = React.useRef(null);
3148
3273
  React.useEffect(() => {
@@ -3161,8 +3286,8 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3161
3286
  const handleClick = (typeId) => {
3162
3287
  // project() is deprecated; use screenToFlowPosition for screen coordinates
3163
3288
  const p = rf.screenToFlowPosition({ x: clientPos.x, y: clientPos.y });
3164
- onAdd(typeId, p);
3165
- onClose();
3289
+ handlers.onAddNode(typeId, { position: p });
3290
+ handlers.onClose();
3166
3291
  };
3167
3292
  const renderTree = (tree, path = []) => {
3168
3293
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
@@ -3184,8 +3309,7 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3184
3309
  }, children: [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", 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" })) })] }));
3185
3310
  }
3186
3311
 
3187
- function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3188
- const { wb, runner, engineKind, registry, outputsMap, outputTypesMap } = useWorkbenchContext();
3312
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
3189
3313
  const ref = React.useRef(null);
3190
3314
  // outside click + ESC
3191
3315
  React.useEffect(() => {
@@ -3195,11 +3319,11 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3195
3319
  if (!ref.current)
3196
3320
  return;
3197
3321
  if (!ref.current.contains(e.target))
3198
- onClose();
3322
+ handlers.onClose();
3199
3323
  };
3200
3324
  const onKey = (e) => {
3201
3325
  if (e.key === "Escape")
3202
- onClose();
3326
+ handlers.onClose();
3203
3327
  };
3204
3328
  window.addEventListener("mousedown", onDown, true);
3205
3329
  window.addEventListener("keydown", onKey);
@@ -3207,48 +3331,26 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3207
3331
  window.removeEventListener("mousedown", onDown, true);
3208
3332
  window.removeEventListener("keydown", onKey);
3209
3333
  };
3210
- }, [open, onClose]);
3334
+ }, [open, handlers]);
3211
3335
  React.useEffect(() => {
3212
3336
  if (open)
3213
3337
  ref.current?.focus();
3214
3338
  }, [open]);
3215
- // Bake helpers
3216
- const getBakeableOutputs = () => {
3217
- try {
3218
- const def = wb.export();
3219
- const node = def.nodes.find((n) => n.nodeId === nodeId);
3220
- if (!node)
3221
- return [];
3222
- const desc = registry.nodes.get(node.typeId);
3223
- const handles = Object.keys(desc?.outputs || {});
3224
- const out = [];
3225
- for (const h of handles) {
3226
- const tId = outputTypesMap?.[nodeId]?.[h];
3227
- if (!tId)
3228
- continue;
3229
- if (tId.endsWith("[]")) {
3230
- const base = tId.slice(0, -2);
3231
- const tArr = registry.types.get(tId);
3232
- const tElem = registry.types.get(base);
3233
- const arrT = tArr?.bakeTarget;
3234
- const elemT = tElem?.bakeTarget;
3235
- if ((arrT && registry.nodes.has(arrT.nodeTypeId)) ||
3236
- (elemT && registry.nodes.has(elemT.nodeTypeId)))
3237
- out.push(h);
3238
- }
3239
- else {
3240
- const t = registry.types.get(tId);
3241
- const bt = t?.bakeTarget;
3242
- if (bt && registry.nodes.has(bt.nodeTypeId))
3243
- out.push(h);
3244
- }
3245
- }
3246
- return out;
3247
- }
3248
- catch {
3249
- return [];
3250
- }
3251
- };
3339
+ if (!open || !clientPos || !nodeId)
3340
+ return null;
3341
+ // clamp
3342
+ const MENU_MIN_WIDTH = 180;
3343
+ const PADDING = 16;
3344
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
3345
+ (MENU_MIN_WIDTH + PADDING));
3346
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
3347
+ 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", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3348
+ e.preventDefault();
3349
+ e.stopPropagation();
3350
+ }, 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" }), 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" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [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))), 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.onCopyId, children: "Copy Node ID" })] }));
3351
+ }
3352
+
3353
+ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap, outputTypesMap, onClose) {
3252
3354
  const doBake = async (handleId) => {
3253
3355
  try {
3254
3356
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
@@ -3281,7 +3383,6 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3281
3383
  const newId = wb.addNode({
3282
3384
  typeId: singleTarget.nodeTypeId,
3283
3385
  position: { x: pos.x + 180, y: pos.y },
3284
- params: {},
3285
3386
  });
3286
3387
  runner.update(wb.export());
3287
3388
  await runner.whenIdle();
@@ -3292,12 +3393,9 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3292
3393
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3293
3394
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3294
3395
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3295
- const newId = `n${Math.random().toString(36).slice(2, 8)}`;
3296
- wb.addNode({
3297
- nodeId: newId,
3396
+ const newId = wb.addNode({
3298
3397
  typeId: arrTarget.nodeTypeId,
3299
3398
  position: { x: pos.x + 180, y: pos.y },
3300
- params: {},
3301
3399
  });
3302
3400
  runner.update(wb.export());
3303
3401
  await runner.whenIdle();
@@ -3315,14 +3413,11 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3315
3413
  const DY = 160;
3316
3414
  const nodeIds = [];
3317
3415
  for (let idx = 0; idx < coercedItems.length; idx++) {
3318
- const cv = coercedItems[idx];
3319
3416
  const col = idx % COLS;
3320
3417
  const row = Math.floor(idx / COLS);
3321
3418
  const newId = wb.addNode({
3322
3419
  typeId: elemTarget.nodeTypeId,
3323
3420
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3324
- params: {},
3325
- initialInputs: { [elemTarget.inputHandle]: structuredClone(cv) },
3326
3421
  });
3327
3422
  nodeIds.push(newId);
3328
3423
  }
@@ -3340,63 +3435,88 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3340
3435
  }
3341
3436
  catch { }
3342
3437
  };
3343
- // actions
3344
- const handleDelete = React.useCallback(() => {
3345
- wb.removeNode(nodeId);
3346
- onClose();
3347
- }, [nodeId, wb, onClose]);
3348
- const handleDuplicate = React.useCallback(() => {
3438
+ return {
3439
+ onDelete: () => {
3440
+ wb.removeNode(nodeId);
3441
+ onClose();
3442
+ },
3443
+ onDuplicate: async () => {
3444
+ const def = wb.export();
3445
+ const n = def.nodes.find((n) => n.nodeId === nodeId);
3446
+ if (!n)
3447
+ return onClose();
3448
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3449
+ const newId = wb.addNode({
3450
+ typeId: n.typeId,
3451
+ params: n.params,
3452
+ position: { x: pos.x + 24, y: pos.y + 24 },
3453
+ initialInputs: n.initialInputs,
3454
+ resolvedHandles: n.resolvedHandles,
3455
+ });
3456
+ await runner.whenIdle();
3457
+ runner.setInputs(newId, { ...runner.getInputs(def)[nodeId] });
3458
+ onClose();
3459
+ },
3460
+ onRunPull: async () => {
3461
+ try {
3462
+ await runner.computeNode(nodeId);
3463
+ }
3464
+ catch { }
3465
+ onClose();
3466
+ },
3467
+ onBake: async (handleId) => {
3468
+ await doBake(handleId);
3469
+ onClose();
3470
+ },
3471
+ onCopyId: async () => {
3472
+ try {
3473
+ await navigator.clipboard.writeText(nodeId);
3474
+ }
3475
+ catch { }
3476
+ onClose();
3477
+ },
3478
+ onClose,
3479
+ };
3480
+ }
3481
+ function getBakeableOutputs(nodeId, wb, registry, outputTypesMap) {
3482
+ try {
3349
3483
  const def = wb.export();
3350
- const n = def.nodes.find((n) => n.nodeId === nodeId);
3351
- if (!n)
3352
- return onClose();
3353
- const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3354
- wb.addNode({
3355
- typeId: n.typeId,
3356
- params: n.params,
3357
- position: { x: pos.x + 24, y: pos.y + 24 },
3358
- });
3359
- onClose();
3360
- }, [nodeId, wb, onClose]);
3361
- React.useCallback(async (handleId) => {
3362
- await doBake(handleId);
3363
- onClose();
3364
- }, [doBake, onClose]);
3365
- const handleCopyId = React.useCallback(async () => {
3366
- try {
3367
- await navigator.clipboard.writeText(nodeId);
3368
- }
3369
- catch { }
3370
- onClose();
3371
- }, [nodeId, onClose]);
3372
- const handleRunPull = React.useCallback(async () => {
3373
- try {
3374
- await runner.computeNode(nodeId);
3484
+ const node = def.nodes.find((n) => n.nodeId === nodeId);
3485
+ if (!node)
3486
+ return [];
3487
+ const desc = registry.nodes.get(node.typeId);
3488
+ const handles = Object.keys(desc?.outputs || {});
3489
+ const out = [];
3490
+ for (const h of handles) {
3491
+ const tId = outputTypesMap?.[nodeId]?.[h];
3492
+ if (!tId)
3493
+ continue;
3494
+ if (tId.endsWith("[]")) {
3495
+ const base = tId.slice(0, -2);
3496
+ const tArr = registry.types.get(tId);
3497
+ const tElem = registry.types.get(base);
3498
+ const arrT = tArr?.bakeTarget;
3499
+ const elemT = tElem?.bakeTarget;
3500
+ if ((arrT && registry.nodes.has(arrT.nodeTypeId)) ||
3501
+ (elemT && registry.nodes.has(elemT.nodeTypeId)))
3502
+ out.push(h);
3503
+ }
3504
+ else {
3505
+ const t = registry.types.get(tId);
3506
+ const bt = t?.bakeTarget;
3507
+ if (bt && registry.nodes.has(bt.nodeTypeId))
3508
+ out.push(h);
3509
+ }
3375
3510
  }
3376
- catch { }
3377
- onClose();
3378
- }, [nodeId, runner, onClose]);
3379
- if (!open || !clientPos || !nodeId)
3380
- return null;
3381
- // clamp
3382
- const MENU_MIN_WIDTH = 180;
3383
- const PADDING = 16;
3384
- const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
3385
- (MENU_MIN_WIDTH + PADDING));
3386
- const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
3387
- const canRunPull = engineKind()?.toString() === "pull";
3388
- const outs = getBakeableOutputs();
3389
- 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", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3390
- e.preventDefault();
3391
- e.stopPropagation();
3392
- }, 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: handleDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDuplicate, children: "Duplicate" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), outs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), outs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: async () => {
3393
- await doBake(h);
3394
- onClose();
3395
- }, children: ["Bake: ", h] }, h))), 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: handleCopyId, children: "Copy Node ID" })] }));
3511
+ return out;
3512
+ }
3513
+ catch {
3514
+ return [];
3515
+ }
3396
3516
  }
3397
3517
 
3398
3518
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
3399
- const { wb, registry, inputsMap, inputDefaultsMap, outputsMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, } = useWorkbenchContext();
3519
+ const { wb, registry, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, runner, engineKind, } = useWorkbenchContext();
3400
3520
  const nodeValidation = validationByNode;
3401
3521
  const edgeValidation = validationByEdge.errors;
3402
3522
  // Keep stable references for nodes/edges to avoid unnecessary updates
@@ -3463,9 +3583,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3463
3583
  },
3464
3584
  }));
3465
3585
  const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, } = useWorkbenchBridge(wb);
3586
+ const ui = wb.getUI();
3466
3587
  const { nodeTypes, resolveNodeType } = React.useMemo(() => {
3467
3588
  // Build nodeTypes map using UI extension registry
3468
- const ui = wb.getUI();
3469
3589
  const custom = new Map(); // Include all types present in registry AND current graph to avoid timing issues
3470
3590
  const def = wb.export();
3471
3591
  const ids = new Set([
@@ -3487,7 +3607,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3487
3607
  const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark-${nodeTypeId}` : "spark-default";
3488
3608
  return { nodeTypes: types, resolveNodeType: resolver };
3489
3609
  // Include uiVersion to recompute when custom renderers are registered
3490
- }, [wb, registry, uiVersion]);
3610
+ }, [wb, registry, uiVersion, ui]);
3491
3611
  const { nodes, edges } = React.useMemo(() => {
3492
3612
  const def = wb.export();
3493
3613
  const sel = wb.getSelection();
@@ -3651,19 +3771,63 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3651
3771
  setNodeMenuOpen(false);
3652
3772
  }
3653
3773
  };
3654
- const addNodeAt = React.useCallback((typeId, pos) => {
3655
- wb.addNode({ typeId, position: pos });
3656
- }, [wb]);
3774
+ const addNodeAt = React.useCallback(async (typeId, opts) => {
3775
+ const nodeId = wb.addNode({
3776
+ typeId,
3777
+ initialInputs: opts.initialInputs,
3778
+ position: opts.position,
3779
+ });
3780
+ if (opts.inputs) {
3781
+ runner.update(wb.export());
3782
+ await runner.whenIdle();
3783
+ runner.setInputs(nodeId, opts.inputs);
3784
+ }
3785
+ }, [wb, runner]);
3657
3786
  const onCloseMenu = React.useCallback(() => {
3658
3787
  setMenuOpen(false);
3659
3788
  }, []);
3660
3789
  const onCloseNodeMenu = React.useCallback(() => {
3661
3790
  setNodeMenuOpen(false);
3662
3791
  }, []);
3792
+ const nodeIds = React.useMemo(() => Array.from(registry.nodes.keys()), [registry]);
3793
+ const defaultContextMenuHandlers = React.useMemo(() => ({
3794
+ onAddNode: addNodeAt,
3795
+ onClose: onCloseMenu,
3796
+ }), [addNodeAt, onCloseMenu]);
3797
+ const nodeContextMenuHandlers = React.useMemo(() => {
3798
+ if (!nodeAtMenu)
3799
+ return null;
3800
+ return createNodeContextMenuHandlers(nodeAtMenu, wb, runner, registry, outputsMap, outputTypesMap, onCloseNodeMenu);
3801
+ }, [
3802
+ nodeAtMenu,
3803
+ wb,
3804
+ runner,
3805
+ registry,
3806
+ outputsMap,
3807
+ outputTypesMap,
3808
+ onCloseNodeMenu,
3809
+ ]);
3810
+ const canRunPull = React.useMemo(() => engineKind()?.toString() === "pull", [engineKind]);
3811
+ const bakeableOutputs = React.useMemo(() => {
3812
+ if (!nodeAtMenu)
3813
+ return [];
3814
+ return getBakeableOutputs(nodeAtMenu, wb, registry, outputTypesMap);
3815
+ }, [nodeAtMenu, wb, registry, outputTypesMap]);
3816
+ // Get custom renderers from UI extension registry (reactive to uiVersion changes)
3817
+ const { BackgroundRenderer, MinimapRenderer, ControlsRenderer, DefaultContextMenuRenderer, NodeContextMenuRenderer, connectionLineRenderer, } = React.useMemo(() => {
3818
+ return {
3819
+ BackgroundRenderer: ui.getBackgroundRenderer(),
3820
+ MinimapRenderer: ui.getMinimapRenderer(),
3821
+ ControlsRenderer: ui.getControlsRenderer(),
3822
+ DefaultContextMenuRenderer: ui.getDefaultContextMenuRenderer(),
3823
+ NodeContextMenuRenderer: ui.getNodeContextMenuRenderer(),
3824
+ connectionLineRenderer: ui.getConnectionLineRenderer(),
3825
+ };
3826
+ }, [ui, uiVersion]);
3663
3827
  const onMoveEnd = React.useCallback(() => {
3664
3828
  if (rfInstanceRef.current) {
3665
3829
  const viewport = rfInstanceRef.current.getViewport();
3666
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
3830
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
3667
3831
  }
3668
3832
  }, [wb]);
3669
3833
  const viewportRef = React.useRef(null);
@@ -3684,7 +3848,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3684
3848
  });
3685
3849
  }
3686
3850
  });
3687
- return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsx(react.ReactFlowProvider, { children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, selectionOnDrag: true, onInit: (inst) => {
3851
+ return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsx(react.ReactFlowProvider, { children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
3688
3852
  rfInstanceRef.current = inst;
3689
3853
  const savedViewport = wb.getViewport();
3690
3854
  if (savedViewport) {
@@ -3695,7 +3859,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3695
3859
  zoom: savedViewport.zoom,
3696
3860
  });
3697
3861
  }
3698
- }, 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: [jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 }), jsxRuntime.jsx(react.MiniMap, {}), jsxRuntime.jsx(react.Controls, {}), jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: onCloseMenu }), !!nodeAtMenu && (jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: onCloseNodeMenu }))] }) }) }));
3862
+ }, 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 &&
3863
+ nodeContextMenuHandlers &&
3864
+ (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 })))] }) }) }));
3699
3865
  });
3700
3866
 
3701
3867
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -3830,9 +3996,20 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3830
3996
  }
3831
3997
  catch { }
3832
3998
  });
3999
+ const off3 = wb.on("graphUiChanged", (evt) => {
4000
+ if (!evt.commit)
4001
+ return;
4002
+ try {
4003
+ const cur = wb.export();
4004
+ const inputs = runner.getInputs(cur);
4005
+ onChange({ def: cur, inputs });
4006
+ }
4007
+ catch { }
4008
+ });
3833
4009
  return () => {
3834
4010
  off1();
3835
4011
  off2();
4012
+ off3();
3836
4013
  };
3837
4014
  }, [wb, runner, onChange]);
3838
4015
  const applyExample = React.useCallback(async (key) => {
@@ -3868,24 +4045,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3868
4045
  setRegistry,
3869
4046
  backendKind,
3870
4047
  ]);
3871
- const downloadGraph = React.useCallback(() => {
4048
+ const download$1 = React.useCallback(async () => {
3872
4049
  try {
3873
- const def = wb.export();
3874
- const inputs = runner.getInputs(def);
3875
- const payload = { def, inputs };
3876
- const pretty = JSON.stringify(payload, null, 2);
3877
- const blob = new Blob([pretty], { type: "application/json" });
3878
- const url = URL.createObjectURL(blob);
3879
- const a = document.createElement("a");
3880
- const d = new Date();
3881
- const pad = (n) => String(n).padStart(2, "0");
3882
- const ts = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
3883
- a.href = url;
3884
- a.download = `spark-graph-${ts}.json`;
3885
- document.body.appendChild(a);
3886
- a.click();
3887
- a.remove();
3888
- URL.revokeObjectURL(url);
4050
+ await download(wb, runner);
3889
4051
  }
3890
4052
  catch (err) {
3891
4053
  const message = err instanceof Error ? err.message : String(err);
@@ -3899,51 +4061,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3899
4061
  return;
3900
4062
  const text = await file.text();
3901
4063
  const parsed = JSON.parse(text);
3902
- // Support both Graph and Snapshot payloads
3903
- const isSnapshot = parsed &&
3904
- typeof parsed === "object" &&
3905
- (parsed.def || parsed.inputs || parsed.outputs || parsed.environment);
3906
- if (isSnapshot) {
3907
- const def = parsed.def;
3908
- const positions = parsed.positions || {};
3909
- const environment = parsed.environment || {};
3910
- const inputs = parsed.inputs || {};
3911
- if (def && runner.isRunning()) {
3912
- // Remote exact restore path
3913
- await runner.applySnapshotFull({
3914
- def,
3915
- environment,
3916
- inputs,
3917
- outputs: parsed.outputs || {},
3918
- });
3919
- await wb.load(def);
3920
- if (positions && typeof positions === "object")
3921
- wb.setPositions(positions);
3922
- }
3923
- else if (!runner.isRunning()) {
3924
- alert("Engine is not running");
3925
- }
3926
- else {
3927
- alert("Graph definition is empty");
3928
- }
3929
- }
3930
- else {
3931
- const def = parsed?.def ?? parsed;
3932
- const inputs = parsed?.inputs ?? {};
3933
- await wb.load(def);
3934
- try {
3935
- runner.build(wb.export());
3936
- }
3937
- catch { }
3938
- if (inputs && typeof inputs === "object") {
3939
- for (const [nodeId, map] of Object.entries(inputs)) {
3940
- try {
3941
- runner.setInputs(nodeId, map);
3942
- }
3943
- catch { }
3944
- }
3945
- }
3946
- }
4064
+ await upload(parsed, wb, runner);
3947
4065
  }
3948
4066
  catch (err) {
3949
4067
  const message = err instanceof Error ? err.message : String(err);
@@ -4199,36 +4317,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
4199
4317
  // Normal change when not running
4200
4318
  onEngineChange?.(kind);
4201
4319
  }
4202
- }, children: [jsxRuntime.jsx("option", { value: "", children: "Select Engine\u2026" }), jsxRuntime.jsx("option", { value: "push", children: "Push" }), jsxRuntime.jsx("option", { value: "batched", children: "Batched" }), jsxRuntime.jsx("option", { value: "pull", children: "Pull" }), jsxRuntime.jsx("option", { value: "hybrid", children: "Hybrid" }), jsxRuntime.jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsxRuntime.jsx(react$1.PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsxRuntime.jsx(react$1.LightningIcon, { size: 24 }) })), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: downloadGraph, children: jsxRuntime.jsx(react$1.DownloadSimpleIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
4203
- try {
4204
- const def = wb.export();
4205
- const positions = wb.getPositions();
4206
- const snapshot = await runner.snapshotFull();
4207
- const payload = {
4208
- ...snapshot,
4209
- def,
4210
- positions,
4211
- schemaVersion: 1,
4212
- };
4213
- const pretty = JSON.stringify(payload, null, 2);
4214
- const blob = new Blob([pretty], { type: "application/json" });
4215
- const url = URL.createObjectURL(blob);
4216
- const a = document.createElement("a");
4217
- const d = new Date();
4218
- const pad = (n) => String(n).padStart(2, "0");
4219
- const ts = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
4220
- a.href = url;
4221
- a.download = `spark-snapshot-${ts}.json`;
4222
- document.body.appendChild(a);
4223
- a.click();
4224
- a.remove();
4225
- URL.revokeObjectURL(url);
4226
- }
4227
- catch (err) {
4228
- const message = err instanceof Error ? err.message : String(err);
4229
- alert(message);
4230
- }
4231
- }, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx(react$1.BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx(react$1.ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4320
+ }, children: [jsxRuntime.jsx("option", { value: "", children: "Select Engine\u2026" }), jsxRuntime.jsx("option", { value: "push", children: "Push" }), jsxRuntime.jsx("option", { value: "batched", children: "Batched" }), jsxRuntime.jsx("option", { value: "pull", children: "Pull" }), jsxRuntime.jsx("option", { value: "hybrid", children: "Hybrid" }), jsxRuntime.jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsxRuntime.jsx(react$1.PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsxRuntime.jsx(react$1.LightningIcon, { size: 24 }) })), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx(react$1.BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx(react$1.ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4232
4321
  }
4233
4322
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
4234
4323
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -4320,6 +4409,7 @@ exports.WorkbenchProvider = WorkbenchProvider;
4320
4409
  exports.WorkbenchStudio = WorkbenchStudio;
4321
4410
  exports.computeEffectiveHandles = computeEffectiveHandles;
4322
4411
  exports.countVisibleHandles = countVisibleHandles;
4412
+ exports.download = download;
4323
4413
  exports.estimateNodeSize = estimateNodeSize;
4324
4414
  exports.formatDataUrlAsLabel = formatDataUrlAsLabel;
4325
4415
  exports.formatDeclaredTypeSignature = formatDeclaredTypeSignature;
@@ -4331,6 +4421,7 @@ exports.prettyHandle = prettyHandle;
4331
4421
  exports.resolveOutputDisplay = resolveOutputDisplay;
4332
4422
  exports.summarizeDeep = summarizeDeep;
4333
4423
  exports.toReactFlow = toReactFlow;
4424
+ exports.upload = upload;
4334
4425
  exports.useQueryParamBoolean = useQueryParamBoolean;
4335
4426
  exports.useQueryParamString = useQueryParamString;
4336
4427
  exports.useThrottledValue = useThrottledValue;