@saltcorn/workflow-editor 1.6.0-alpha.5 → 1.6.0-alpha.7

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.
@@ -10,6 +10,7 @@ import {
10
10
  Controls,
11
11
  Handle,
12
12
  MiniMap,
13
+ MarkerType,
13
14
  Position,
14
15
  useEdgesState,
15
16
  useNodesState,
@@ -33,6 +34,13 @@ const H_GAP = 80;
33
34
  const V_GAP = 60;
34
35
  const ADD_NODE_SIZE = 32;
35
36
  const ADD_GAP = 32;
37
+ const DEFAULT_ARROW_COLOR = "var(--wf-edge-label, #6c757d)";
38
+ const makeMarker = (color = DEFAULT_ARROW_COLOR) => ({
39
+ type: MarkerType.ArrowClosed,
40
+ width: 18,
41
+ height: 18,
42
+ color,
43
+ });
36
44
 
37
45
  const normalizeSize = (size) => {
38
46
  if (!size) return null;
@@ -52,7 +60,8 @@ const StartNode = ({ data }) => (
52
60
  style={handleStyle}
53
61
  id="start"
54
62
  type="source"
55
- position={Position.Right}
63
+ className="wf-handle-source"
64
+ position={Position.Bottom}
56
65
  />
57
66
  </div>
58
67
  );
@@ -78,7 +87,7 @@ const AddNode = ({ data }) => {
78
87
  onKeyDown={handleKeyDown}
79
88
  >
80
89
  <span aria-hidden>+</span>
81
- <Handle type="target" position={Position.Left} />
90
+ <Handle type="target" position={Position.Top} />
82
91
  </div>
83
92
  );
84
93
  };
@@ -108,11 +117,24 @@ const StepNode = ({ data }) => {
108
117
  }
109
118
  };
110
119
 
111
- const isLoopBody = data.inLoopBody;
112
- const useVerticalHandles =
113
- isLoopBody && String(data.action_name) !== "ForLoop";
114
- const targetPosition = useVerticalHandles ? Position.Top : Position.Left;
115
- const sourcePosition = useVerticalHandles ? Position.Bottom : Position.Right;
120
+ const targetPosition = Position.Top;
121
+ const sourcePosition = Position.Bottom;
122
+ const isLoop = data.action_name === "ForLoop";
123
+ const { showBackIn, showBackOut, showLoopIn, showLoopOut, hideMainSource } =
124
+ data || {};
125
+
126
+ const hasLeftSource = !!showBackOut;
127
+ const hasLeftTarget = !!showBackIn || !!showLoopIn;
128
+
129
+ let backInStyle = handleStyle;
130
+ let loopInStyle = handleStyle;
131
+ let backOutStyle = handleStyle;
132
+
133
+ if (hasLeftSource && hasLeftTarget) {
134
+ backInStyle = { ...handleStyle, top: "68%" };
135
+ loopInStyle = { ...handleStyle, top: "32%" };
136
+ backOutStyle = { ...handleStyle, top: "32%" };
137
+ }
116
138
 
117
139
  return (
118
140
  <div
@@ -154,12 +176,55 @@ const StepNode = ({ data }) => {
154
176
  </div>
155
177
  ) : null} */}
156
178
  <Handle style={handleStyle} type="target" position={targetPosition} />
157
- <Handle
158
- style={handleStyle}
159
- id="main"
160
- type="source"
161
- position={sourcePosition}
162
- />
179
+ {!hideMainSource ? (
180
+ <Handle
181
+ style={handleStyle}
182
+ id="main"
183
+ type="source"
184
+ className="wf-handle-source"
185
+ position={sourcePosition}
186
+ />
187
+ ) : null}
188
+ {/* Back edges use left handles to untangle cross-links */}
189
+ {showBackIn ? (
190
+ <Handle
191
+ style={backInStyle}
192
+ id="back-in"
193
+ type="target"
194
+ position={Position.Left}
195
+ />
196
+ ) : null}
197
+ {showBackOut ? (
198
+ <Handle
199
+ style={backOutStyle}
200
+ id="back-out"
201
+ type="source"
202
+ className="wf-handle-source"
203
+ position={Position.Left}
204
+ />
205
+ ) : null}
206
+ {isLoop ? (
207
+ <>
208
+ {showLoopIn ? (
209
+ <Handle
210
+ style={loopInStyle}
211
+ id="loop-in"
212
+ type="target"
213
+ className="wf-handle-target-loop"
214
+ position={Position.Left}
215
+ />
216
+ ) : null}
217
+ {showLoopOut ? (
218
+ <Handle
219
+ style={handleStyle}
220
+ id="loop-out"
221
+ type="source"
222
+ className="wf-handle-source-loop"
223
+ position={Position.Right}
224
+ />
225
+ ) : null}
226
+ </>
227
+ ) : null}
163
228
  </div>
164
229
  );
165
230
  };
@@ -234,6 +299,7 @@ const buildGraph = (
234
299
  });
235
300
 
236
301
  const allStepNames = steps.map((s) => s.name).filter(Boolean);
302
+ const orderIndex = new Map(steps.map((s, idx) => [String(s.id), idx]));
237
303
 
238
304
  const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
305
 
@@ -264,6 +330,26 @@ const buildGraph = (
264
330
  const loopBackLinks = findLoopBackLinks(steps);
265
331
  const loopBodyIds = findLoopBodyStepIds(steps);
266
332
 
333
+ // Track which handles are actualy used by edges per node
334
+ const handleUsage = {};
335
+ const markHandleUsage = (nodeId, handleId) => {
336
+ if (!nodeId || !handleId) return;
337
+ const key = String(nodeId);
338
+ if (!handleUsage[key]) handleUsage[key] = {};
339
+ handleUsage[key][handleId] = true;
340
+ };
341
+
342
+ // Track whether a node has forward/back edges to decide when to hide main bottom handle
343
+ const edgeStats = {};
344
+ const markEdgeKind = (nodeId, { back = false, forward = false } = {}) => {
345
+ if (!nodeId) return;
346
+ const key = String(nodeId);
347
+ if (!edgeStats[key])
348
+ edgeStats[key] = { hasBackEdge: false, hasForwardEdge: false };
349
+ if (back) edgeStats[key].hasBackEdge = true;
350
+ if (forward) edgeStats[key].hasForwardEdge = true;
351
+ };
352
+
267
353
  const adjacency = new Map();
268
354
  steps.forEach((s) => {
269
355
  const targets = [];
@@ -275,62 +361,33 @@ const buildGraph = (
275
361
  adjacency.set(String(s.id), [...new Set(targets)]);
276
362
  });
277
363
 
278
- const depths = {};
279
- const queue = [];
364
+ // Revising logical ordering based on workflow graph traversal from initial step
280
365
  if (initial) {
281
- depths[String(initial.id)] = 1;
282
- queue.push(String(initial.id));
283
- }
284
-
285
- while (queue.length) {
286
- const current = queue.shift();
287
- const d = depths[current];
288
- (adjacency.get(current) || []).forEach((n) => {
289
- if (depths[n] === undefined) {
290
- depths[n] = d + 1;
291
- queue.push(n);
292
- }
293
- });
294
- }
295
-
296
- let maxDepth = Object.values(depths).reduce((m, v) => Math.max(m, v), 0);
297
- const sortedRemaining = steps
298
- .map((s) => String(s.id))
299
- .filter((id) => depths[id] === undefined)
300
- .sort();
301
- if (sortedRemaining.length) {
302
- const orphanDepth = maxDepth + 1;
303
- sortedRemaining.forEach((id) => {
304
- depths[id] = orphanDepth;
305
- });
306
- maxDepth = orphanDepth;
366
+ const visited = new Set();
367
+ const queue = [String(initial.id)];
368
+ let index = 0;
369
+ while (queue.length) {
370
+ const nodeId = queue.shift();
371
+ if (visited.has(nodeId)) continue;
372
+ visited.add(nodeId);
373
+ orderIndex.set(nodeId, index++);
374
+ const neighbors = adjacency.get(nodeId) || [];
375
+ neighbors.forEach((n) => {
376
+ if (!visited.has(n)) queue.push(n);
377
+ });
378
+ }
307
379
  }
308
380
 
309
- const groupByDepth = new Map();
310
- steps.forEach((s) => {
311
- const d = depths[String(s.id)] || 1;
312
- if (!groupByDepth.has(d)) groupByDepth.set(d, []);
313
- groupByDepth.get(d).push(String(s.id));
314
- });
315
- [...groupByDepth.values()].forEach((arr) => arr.sort());
316
-
317
381
  const positions = {};
318
- const depthEntries = [...groupByDepth.entries()].sort(([a], [b]) => a - b);
319
- const depthX = {};
320
- let xCursor = 0;
321
- depthEntries.forEach(([d, ids]) => {
322
- const colWidth = Math.max(
323
- ...ids.map((id) => sizeById[id]?.width || DEFAULT_NODE_WIDTH)
324
- );
325
- depthX[d] = xCursor;
326
- xCursor += colWidth + H_GAP;
327
- });
328
- depthEntries.forEach(([d, ids]) => {
329
- let yCursor = 0;
330
- ids.forEach((id) => {
331
- positions[id] = { x: depthX[d], y: yCursor };
332
- yCursor += (sizeById[id]?.height || DEFAULT_NODE_HEIGHT) + V_GAP;
333
- });
382
+ let yCursor = 0;
383
+ steps.forEach((s) => {
384
+ const id = String(s.id);
385
+ const size = sizeById[id] || {
386
+ width: DEFAULT_NODE_WIDTH,
387
+ height: DEFAULT_NODE_HEIGHT,
388
+ };
389
+ positions[id] = { x: 0, y: yCursor };
390
+ yCursor += size.height + V_GAP;
334
391
  });
335
392
 
336
393
  // Nudge ForLoop bodies to be vertical under their loop parent for readability
@@ -378,10 +435,10 @@ const buildGraph = (
378
435
  startPosition ||
379
436
  (initial
380
437
  ? {
381
- x: positions[String(initial.id)]?.x - 180 || -180,
382
- y: positions[String(initial.id)]?.y || 40,
438
+ x: positions[String(initial.id)]?.x ?? 0,
439
+ y: (positions[String(initial.id)]?.y ?? 0) - 160,
383
440
  }
384
- : { x: -180, y: 40 });
441
+ : { x: 0, y: -160 });
385
442
 
386
443
  const nodes = [
387
444
  {
@@ -431,6 +488,7 @@ const buildGraph = (
431
488
  target: String(initial.id),
432
489
  type: "smoothstep",
433
490
  animated: true,
491
+ markerEnd: makeMarker(),
434
492
  });
435
493
  else {
436
494
  const addId = "add-start";
@@ -445,6 +503,7 @@ const buildGraph = (
445
503
  strokeDasharray: "4 2",
446
504
  stroke: "var(--wf-edge-adder, #0d6efd)",
447
505
  },
506
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
448
507
  });
449
508
  }
450
509
 
@@ -458,12 +517,42 @@ const buildGraph = (
458
517
  nextNames.forEach((name) => {
459
518
  const targetId = idByName[name];
460
519
  if (!targetId) return;
520
+ const srcOrder = orderIndex.get(String(step.id));
521
+ const tgtOrder = orderIndex.get(targetId);
522
+ const srcPos = nodePositions[String(step.id)];
523
+ const tgtPos = nodePositions[targetId];
524
+ const isBackByOrder =
525
+ srcOrder !== undefined &&
526
+ tgtOrder !== undefined &&
527
+ tgtOrder < srcOrder;
528
+ const isBackVisually =
529
+ srcPos &&
530
+ tgtPos &&
531
+ typeof srcPos.y === "number" &&
532
+ typeof tgtPos.y === "number"
533
+ ? tgtPos.y < srcPos.y
534
+ : false;
535
+ const isBackEdge = isBackByOrder && isBackVisually;
536
+ // record handle usage and edge direction for this source node
537
+ const sourceId = String(step.id);
538
+ if (isBackEdge) {
539
+ markHandleUsage(sourceId, "back-out");
540
+ markHandleUsage(targetId, "back-in");
541
+ markEdgeKind(sourceId, { back: true });
542
+ } else {
543
+ // uses the main bottom source handle
544
+ markHandleUsage(sourceId, "main");
545
+ markEdgeKind(sourceId, { forward: true });
546
+ }
461
547
  edges.push({
462
548
  id: `e-${step.id}-${name}`,
463
549
  source: String(step.id),
464
550
  target: targetId,
465
551
  type: "workflow-main",
466
552
  animated: true,
553
+ sourceHandle: isBackEdge ? "back-out" : undefined,
554
+ targetHandle: isBackEdge ? "back-in" : undefined,
555
+ markerEnd: makeMarker(),
467
556
  data: {
468
557
  startLabel: step.name,
469
558
  sourceStepName: step.name,
@@ -478,17 +567,25 @@ const buildGraph = (
478
567
  const loopTarget = step.configuration?.loop_body_initial_step;
479
568
  if (loopTarget) {
480
569
  const loopId = idByName[loopTarget];
570
+ const sourceId = String(step.id);
571
+ markHandleUsage(sourceId, "loop-out");
572
+ markEdgeKind(sourceId, { forward: true });
573
+ if (!loopId) {
574
+ markHandleUsage(sourceId, "loop-in");
575
+ }
481
576
  edges.push({
482
577
  id: `loop-${step.id}-${loopTarget}`,
483
578
  source: String(step.id),
484
579
  target: loopId || String(step.id),
485
580
  type: "smoothstep",
581
+ sourceHandle: "loop-out",
582
+ targetHandle: loopId ? undefined : "loop-in",
486
583
  style: {
487
584
  stroke: "var(--wf-edge-loop, #f59f00)",
488
585
  strokeDasharray: "6 4",
489
586
  },
490
587
  label: strings.loopBody,
491
- markerEnd: "arrowclosed",
588
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
492
589
  data: { loop: true, missing: !loopId },
493
590
  });
494
591
  }
@@ -498,17 +595,23 @@ const buildGraph = (
498
595
  const forLoopName = loopBackLinks[step.name];
499
596
  const loopId = idByName[forLoopName];
500
597
  if (loopId) {
598
+ const sourceId = String(step.id);
599
+ markHandleUsage(sourceId, "back-out");
600
+ markHandleUsage(loopId, "loop-in");
601
+ markEdgeKind(sourceId, { back: true });
501
602
  edges.push({
502
603
  id: `loopback-${step.id}-${forLoopName}`,
503
604
  source: String(step.id),
504
605
  target: loopId,
505
606
  type: "smoothstep",
607
+ sourceHandle: "back-out",
608
+ targetHandle: "loop-in",
506
609
  style: {
507
610
  stroke: "var(--wf-edge-loop, #f59f00)",
508
611
  strokeDasharray: "6 4",
509
612
  },
510
613
  data: { loop: true, loopBack: true },
511
- markerEnd: "arrowclosed",
614
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
512
615
  deletable: false,
513
616
  });
514
617
  }
@@ -522,8 +625,8 @@ const buildGraph = (
522
625
  id: addId,
523
626
  type: "add",
524
627
  position: addPositions?.[addId] || {
525
- x: startNodePosition.x + 160,
526
- y: startNodePosition.y,
628
+ x: startNodePosition.x,
629
+ y: startNodePosition.y + 160,
527
630
  },
528
631
  data: { strings, afterStepId: "start" },
529
632
  draggable: true,
@@ -544,8 +647,8 @@ const buildGraph = (
544
647
  id: addId,
545
648
  type: "add",
546
649
  position: addPositions?.[addId] || {
547
- x: basePos.x + baseSize.width + ADD_GAP,
548
- y: basePos.y + baseSize.height / 2 - ADD_NODE_SIZE / 2,
650
+ x: basePos.x + baseSize.width / 2 - ADD_NODE_SIZE / 2,
651
+ y: basePos.y + baseSize.height + ADD_GAP,
549
652
  },
550
653
  data: { strings, afterStepId: String(step.id) },
551
654
  draggable: true,
@@ -563,15 +666,37 @@ const buildGraph = (
563
666
  strokeDasharray: "4 2",
564
667
  stroke: "var(--wf-edge-adder, #0d6efd)",
565
668
  },
669
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
566
670
  });
567
671
  });
568
672
 
569
- const allNodes = [...nodes, ...addNodes];
673
+ const allNodes = [...nodes, ...addNodes].map((node) => {
674
+ if (node.type !== "step") return node;
675
+ const id = String(node.id);
676
+ const usage = handleUsage[id] || {};
677
+ const stats = edgeStats[id] || {
678
+ hasBackEdge: false,
679
+ hasForwardEdge: false,
680
+ };
681
+ const hideMainSource = stats.hasBackEdge && !stats.hasForwardEdge;
682
+
683
+ return {
684
+ ...node,
685
+ data: {
686
+ ...node.data,
687
+ showBackIn: !!usage["back-in"],
688
+ showBackOut: !!usage["back-out"],
689
+ showLoopIn: !!usage["loop-in"],
690
+ showLoopOut: !!usage["loop-out"],
691
+ hideMainSource,
692
+ },
693
+ };
694
+ });
570
695
 
571
696
  return { nodes: allNodes, edges, idByName, nameById };
572
697
  };
573
698
 
574
- const StepModal = ({
699
+ const StepDrawer = ({
575
700
  modal,
576
701
  innerRef,
577
702
  onClose,
@@ -579,52 +704,70 @@ const StepModal = ({
579
704
  error,
580
705
  data,
581
706
  onDelete,
707
+ drawerWidth,
708
+ onResizeStart,
582
709
  }) => {
583
- if (!modal) return null;
710
+ const isOpen = !!modal;
584
711
  return (
585
- <div className="wf-modal-backdrop">
586
- <div className="wf-modal card shadow">
587
- <div className="card-header d-flex justify-content-between align-items-center">
588
- <h5 className="mb-0">{modal.title}</h5>
589
- <button className="btn-close" onClick={onClose} aria-label="Close" />
590
- </div>
591
- <div className="card-body">
592
- <div
593
- ref={innerRef}
594
- dangerouslySetInnerHTML={{ __html: modal.body }}
595
- />
596
- {error ? (
597
- <div className="alert alert-danger mt-3">{error}</div>
598
- ) : null}
599
- </div>
600
- <div className="card-footer d-flex justify-content-end gap-2">
601
- <button
602
- className="btn btn-sm btn-outline-danger"
603
- onClick={(e) => {
604
- e.stopPropagation();
605
- onDelete(modal.stepId);
606
- }}
607
- >
608
- {data.strings.deleteStep}
609
- </button>
610
- <button className="btn btn-secondary" onClick={onClose}>
611
- Close
612
- </button>
613
- <button
614
- className="btn btn-primary"
615
- onClick={() =>
616
- innerRef.current
617
- ?.querySelector("form")
618
- ?.dispatchEvent(
619
- new Event("submit", { cancelable: true, bubbles: true })
620
- )
621
- }
622
- disabled={submitting}
623
- >
624
- {submitting ? "Saving..." : "Save"}
625
- </button>
626
- </div>
627
- </div>
712
+ <div
713
+ className={`wf-drawer${isOpen ? " wf-drawer--open" : ""}`}
714
+ role="dialog"
715
+ aria-modal="false"
716
+ aria-hidden={!isOpen}
717
+ aria-label={modal?.title || data?.strings?.configure}
718
+ style={{ "--wf-drawer-width": `${drawerWidth}px` }}
719
+ >
720
+ {modal ? (
721
+ <>
722
+ <div className="wf-drawer__resize" onPointerDown={onResizeStart} />
723
+ <div className="wf-drawer__header d-flex justify-content-between align-items-center">
724
+ <div>
725
+ <h5 className="mb-0">{modal.title}</h5>
726
+ </div>
727
+ <button
728
+ className="btn-close"
729
+ onClick={onClose}
730
+ aria-label="Close"
731
+ />
732
+ </div>
733
+ <div className="wf-drawer__body">
734
+ <div
735
+ ref={innerRef}
736
+ dangerouslySetInnerHTML={{ __html: modal.body }}
737
+ />
738
+ {error ? (
739
+ <div className="alert alert-danger mt-3">{error}</div>
740
+ ) : null}
741
+ </div>
742
+ <div className="wf-drawer__footer d-flex justify-content-end gap-2">
743
+ <button
744
+ className="btn btn-sm btn-outline-danger"
745
+ onClick={(e) => {
746
+ e.stopPropagation();
747
+ onDelete(modal.stepId);
748
+ }}
749
+ >
750
+ {data.strings.deleteStep}
751
+ </button>
752
+ <button className="btn btn-secondary" onClick={onClose}>
753
+ Close
754
+ </button>
755
+ <button
756
+ className="btn btn-primary"
757
+ onClick={() =>
758
+ innerRef.current
759
+ ?.querySelector("form")
760
+ ?.dispatchEvent(
761
+ new Event("submit", { cancelable: true, bubbles: true })
762
+ )
763
+ }
764
+ disabled={submitting}
765
+ >
766
+ {submitting ? "Saving..." : "Save"}
767
+ </button>
768
+ </div>
769
+ </>
770
+ ) : null}
628
771
  </div>
629
772
  );
630
773
  };
@@ -649,7 +792,16 @@ const WorkflowEditor = ({ data }) => {
649
792
  const [savingModal, setSavingModal] = useState(false);
650
793
  const [savingPositions, setSavingPositions] = useState(false);
651
794
  const [sizeSyncing, setSizeSyncing] = useState(false);
795
+ const [drawerWidth, setDrawerWidth] = useState(() => {
796
+ if (typeof window !== "undefined") {
797
+ return Math.max(600, Math.round(window.innerWidth * 0.5));
798
+ }
799
+ return 600;
800
+ });
652
801
  const modalRef = useRef(null);
802
+ const resizingRef = useRef(false);
803
+ const connectSourceRef = useRef(null);
804
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
653
805
 
654
806
  const rfInstanceRef = useRef(null);
655
807
 
@@ -659,9 +811,10 @@ const WorkflowEditor = ({ data }) => {
659
811
 
660
812
  const [selectedNodes, setSelectedNodes] = useState([]);
661
813
 
662
- const handleSelectionChange = useCallback(({ nodes = [] }) => {
663
- console.log("selection change", nodes);
814
+ const handleSelectionChange = useCallback(({ nodes = [], edges = [] }) => {
664
815
  setSelectedNodes(nodes);
816
+ if (edges.length) setSelectedEdgeId(edges[0].id);
817
+ else setSelectedEdgeId(null);
665
818
  }, []);
666
819
  const handleKeyDelete = useCallback(
667
820
  (e) => {
@@ -744,10 +897,12 @@ const WorkflowEditor = ({ data }) => {
744
897
  const stepNode = stepById.get(node.id.replace("add-", ""));
745
898
  if (!stepNode) return;
746
899
 
747
- const nextX = stepNode.position.x + stepNode.width + ADD_GAP;
900
+ const nextX =
901
+ stepNode.position.x +
902
+ stepNode.width / 2 -
903
+ (node.width || ADD_NODE_SIZE) / 2;
748
904
 
749
- const nextY =
750
- stepNode.position.y + stepNode.height / 2 - (node.height || 24) / 2;
905
+ const nextY = stepNode.position.y + stepNode.height + ADD_GAP;
751
906
 
752
907
  const nextPosition = { x: nextX, y: nextY };
753
908
 
@@ -1005,8 +1160,11 @@ const WorkflowEditor = ({ data }) => {
1005
1160
  if (!formEl) return undefined;
1006
1161
  // Re-run show-if logic so action-specific fields render correctly
1007
1162
  setTimeout(() => {
1008
- if (typeof window !== "undefined" && window.apply_showif)
1009
- window.apply_showif();
1163
+ if (typeof window !== "undefined") {
1164
+ if (window.apply_showif) window.apply_showif();
1165
+ // Initialize Monaco and any other dynamic widgets inside the drawer
1166
+ if (window.initialize_page) window.initialize_page();
1167
+ }
1010
1168
  }, 0);
1011
1169
 
1012
1170
  const actionSelect = formEl.querySelector('[name="wf_action_name"]');
@@ -1054,6 +1212,9 @@ const WorkflowEditor = ({ data }) => {
1054
1212
 
1055
1213
  const onConnect = useCallback(
1056
1214
  async (connection) => {
1215
+ // Valid connection landed; clear pending source so we don't treat it as a cancel
1216
+ connectSourceRef.current = null;
1217
+ setSelectedEdgeId(connection.id || null);
1057
1218
  const loop = connection.sourceHandle === "loop";
1058
1219
  const targetName = nameById[connection.target];
1059
1220
  if (connection.source === "start") {
@@ -1074,12 +1235,22 @@ const WorkflowEditor = ({ data }) => {
1074
1235
  const srcStep = steps.find(
1075
1236
  (s) => String(s.id) === String(connection.source)
1076
1237
  );
1238
+ const tgtStep = steps.find(
1239
+ (s) => String(s.id) === String(connection.target)
1240
+ );
1077
1241
  const rawNext = srcStep?.next_step
1078
1242
  ? String(srcStep.next_step).trim()
1079
1243
  : "";
1080
1244
  const simpleNextId = rawNext && idByName[rawNext];
1245
+ const targetHasNext = !!(
1246
+ tgtStep && tgtStep.next_step && String(tgtStep.next_step).trim()
1247
+ );
1081
1248
 
1082
- if (simpleNextId && simpleNextId !== connection.target) {
1249
+ if (
1250
+ simpleNextId &&
1251
+ simpleNextId !== connection.target &&
1252
+ !targetHasNext
1253
+ ) {
1083
1254
  // Rewire: source -> new target, new target -> old target name
1084
1255
  await updateConnection({
1085
1256
  step_id: connection.target,
@@ -1097,6 +1268,28 @@ const WorkflowEditor = ({ data }) => {
1097
1268
  [idByName, nameById, reload, steps, updateConnection]
1098
1269
  );
1099
1270
 
1271
+ const onConnectStart = useCallback((_, params) => {
1272
+ if (params?.nodeId && params?.handleType === "source") {
1273
+ connectSourceRef.current = params;
1274
+ }
1275
+ }, []);
1276
+
1277
+ const onConnectEnd = useCallback(
1278
+ (event) => {
1279
+ const pending = connectSourceRef.current;
1280
+ connectSourceRef.current = null;
1281
+ if (!pending) return;
1282
+ const targetEl = event?.target;
1283
+ if (targetEl?.classList?.contains("react-flow__pane")) {
1284
+ // Dropped on empty space: clear next_step mark as final/unconnected
1285
+ if (pending.nodeId && pending.handleId !== "loop-out") {
1286
+ onClearNext(pending.nodeId);
1287
+ }
1288
+ }
1289
+ },
1290
+ [onClearNext]
1291
+ );
1292
+
1100
1293
  const onEdgesChange = useCallback(
1101
1294
  (changes) => {
1102
1295
  const removed = changes.filter((c) => c.type === "remove");
@@ -1289,9 +1482,63 @@ const WorkflowEditor = ({ data }) => {
1289
1482
  }, [onInsertBetween, setEdges, edges]);
1290
1483
 
1291
1484
  const hasSteps = steps.length > 0;
1485
+ const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
1486
+ const shellStyle = { "--wf-drawer-width": `${drawerWidth}px` };
1487
+ const showMiniMap = !modal;
1488
+
1489
+ const onEdgeClick = useCallback((_, edge) => {
1490
+ setSelectedEdgeId(edge.id);
1491
+ }, []);
1492
+
1493
+ useEffect(() => {
1494
+ setEdges((edgs) =>
1495
+ edgs.map((e) => {
1496
+ const selected = e.id === selectedEdgeId;
1497
+ const nextClass = selected
1498
+ ? `${e.className || ""} wf-edge-selected`.trim()
1499
+ : (e.className || "").replace(/\s*wf-edge-selected\b/, "");
1500
+ const nextData =
1501
+ e.type === "workflow-main" ? { ...(e.data || {}), selected } : e.data;
1502
+ if (nextClass === (e.className || "") && nextData === e.data) return e;
1503
+ return { ...e, className: nextClass, data: nextData };
1504
+ })
1505
+ );
1506
+ }, [selectedEdgeId, setEdges]);
1507
+
1508
+ const handleResize = useCallback((e) => {
1509
+ if (!resizingRef.current) return;
1510
+ const maxWidth = Math.min(900, Math.max(360, window.innerWidth - 40));
1511
+ const nextWidth = Math.min(
1512
+ Math.max(600, window.innerWidth - e.clientX),
1513
+ maxWidth
1514
+ );
1515
+ setDrawerWidth(nextWidth);
1516
+ }, []);
1517
+
1518
+ const stopResize = useCallback(() => {
1519
+ if (!resizingRef.current) return;
1520
+ resizingRef.current = false;
1521
+ document.body.style.userSelect = "";
1522
+ window.removeEventListener("pointermove", handleResize);
1523
+ window.removeEventListener("pointerup", stopResize);
1524
+ }, [handleResize]);
1525
+
1526
+ const startResize = useCallback(
1527
+ (e) => {
1528
+ if (!modal) return;
1529
+ resizingRef.current = true;
1530
+ document.body.style.userSelect = "none";
1531
+ window.addEventListener("pointermove", handleResize);
1532
+ window.addEventListener("pointerup", stopResize);
1533
+ e.preventDefault();
1534
+ },
1535
+ [handleResize, modal, stopResize]
1536
+ );
1537
+
1538
+ useEffect(() => () => stopResize(), [stopResize]);
1292
1539
 
1293
1540
  return (
1294
- <div className="wf-shell">
1541
+ <div className={shellClass} style={shellStyle}>
1295
1542
  <div className="wf-toolbar">
1296
1543
  <div className="wf-toolbar__left">
1297
1544
  <strong>{strings.configure}</strong>
@@ -1335,6 +1582,9 @@ const WorkflowEditor = ({ data }) => {
1335
1582
  onNodesChange={onNodesChangeWrapped}
1336
1583
  onEdgesChange={onEdgesChange}
1337
1584
  onConnect={onConnect}
1585
+ onConnectStart={onConnectStart}
1586
+ onConnectEnd={onConnectEnd}
1587
+ onEdgeClick={onEdgeClick}
1338
1588
  onSelectionChange={handleSelectionChange}
1339
1589
  nodeTypes={nodeTypes}
1340
1590
  edgeTypes={edgeTypes}
@@ -1344,12 +1594,12 @@ const WorkflowEditor = ({ data }) => {
1344
1594
  defaultEdgeOptions={{ animated: true }}
1345
1595
  colorMode={window._sc_lightmode || "light"}
1346
1596
  >
1347
- <MiniMap pannable zoomable />
1597
+ {showMiniMap && <MiniMap pannable zoomable />}
1348
1598
  <Controls />
1349
1599
  <Background gap={16} />
1350
1600
  </ReactFlow>
1351
1601
  </div>
1352
- <StepModal
1602
+ <StepDrawer
1353
1603
  modal={modal}
1354
1604
  innerRef={modalRef}
1355
1605
  onClose={() => {
@@ -1360,6 +1610,8 @@ const WorkflowEditor = ({ data }) => {
1360
1610
  error={error && modal ? error : ""}
1361
1611
  data={data}
1362
1612
  onDelete={onDelete}
1613
+ drawerWidth={drawerWidth}
1614
+ onResizeStart={startResize}
1363
1615
  />
1364
1616
  </div>
1365
1617
  );