@saltcorn/workflow-editor 1.6.0-alpha.6 → 1.6.0-alpha.8

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.
@@ -4,12 +4,14 @@ import React, {
4
4
  useRef,
5
5
  useState,
6
6
  useLayoutEffect,
7
+ useMemo,
7
8
  } from "react";
8
9
  import {
9
10
  Background,
10
11
  Controls,
11
12
  Handle,
12
13
  MiniMap,
14
+ MarkerType,
13
15
  Position,
14
16
  useEdgesState,
15
17
  useNodesState,
@@ -33,6 +35,13 @@ const H_GAP = 80;
33
35
  const V_GAP = 60;
34
36
  const ADD_NODE_SIZE = 32;
35
37
  const ADD_GAP = 32;
38
+ const DEFAULT_ARROW_COLOR = "var(--wf-edge-label, #6c757d)";
39
+ const makeMarker = (color = DEFAULT_ARROW_COLOR) => ({
40
+ type: MarkerType.ArrowClosed,
41
+ width: 18,
42
+ height: 18,
43
+ color,
44
+ });
36
45
 
37
46
  const normalizeSize = (size) => {
38
47
  if (!size) return null;
@@ -52,7 +61,8 @@ const StartNode = ({ data }) => (
52
61
  style={handleStyle}
53
62
  id="start"
54
63
  type="source"
55
- position={Position.Right}
64
+ className="wf-handle-source"
65
+ position={Position.Bottom}
56
66
  />
57
67
  </div>
58
68
  );
@@ -78,7 +88,7 @@ const AddNode = ({ data }) => {
78
88
  onKeyDown={handleKeyDown}
79
89
  >
80
90
  <span aria-hidden>+</span>
81
- <Handle type="target" position={Position.Left} />
91
+ <Handle type="target" position={Position.Top} />
82
92
  </div>
83
93
  );
84
94
  };
@@ -108,11 +118,24 @@ const StepNode = ({ data }) => {
108
118
  }
109
119
  };
110
120
 
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;
121
+ const targetPosition = Position.Top;
122
+ const sourcePosition = Position.Bottom;
123
+ const isLoop = data.action_name === "ForLoop";
124
+ const { showBackIn, showBackOut, showLoopIn, showLoopOut, hideMainSource } =
125
+ data || {};
126
+
127
+ const hasLeftSource = !!showBackOut;
128
+ const hasLeftTarget = !!showBackIn || !!showLoopIn;
129
+
130
+ let backInStyle = handleStyle;
131
+ let loopInStyle = handleStyle;
132
+ let backOutStyle = handleStyle;
133
+
134
+ if (hasLeftSource && hasLeftTarget) {
135
+ backInStyle = { ...handleStyle, top: "68%" };
136
+ loopInStyle = { ...handleStyle, top: "32%" };
137
+ backOutStyle = { ...handleStyle, top: "32%" };
138
+ }
116
139
 
117
140
  return (
118
141
  <div
@@ -154,12 +177,55 @@ const StepNode = ({ data }) => {
154
177
  </div>
155
178
  ) : null} */}
156
179
  <Handle style={handleStyle} type="target" position={targetPosition} />
157
- <Handle
158
- style={handleStyle}
159
- id="main"
160
- type="source"
161
- position={sourcePosition}
162
- />
180
+ {!hideMainSource ? (
181
+ <Handle
182
+ style={handleStyle}
183
+ id="main"
184
+ type="source"
185
+ className="wf-handle-source"
186
+ position={sourcePosition}
187
+ />
188
+ ) : null}
189
+ {/* Back edges use left handles to untangle cross-links */}
190
+ {showBackIn ? (
191
+ <Handle
192
+ style={backInStyle}
193
+ id="back-in"
194
+ type="target"
195
+ position={Position.Left}
196
+ />
197
+ ) : null}
198
+ {showBackOut ? (
199
+ <Handle
200
+ style={backOutStyle}
201
+ id="back-out"
202
+ type="source"
203
+ className="wf-handle-source"
204
+ position={Position.Left}
205
+ />
206
+ ) : null}
207
+ {isLoop ? (
208
+ <>
209
+ {showLoopIn ? (
210
+ <Handle
211
+ style={loopInStyle}
212
+ id="loop-in"
213
+ type="target"
214
+ className="wf-handle-target-loop"
215
+ position={Position.Left}
216
+ />
217
+ ) : null}
218
+ {showLoopOut ? (
219
+ <Handle
220
+ style={handleStyle}
221
+ id="loop-out"
222
+ type="source"
223
+ className="wf-handle-source-loop"
224
+ position={Position.Right}
225
+ />
226
+ ) : null}
227
+ </>
228
+ ) : null}
163
229
  </div>
164
230
  );
165
231
  };
@@ -234,6 +300,7 @@ const buildGraph = (
234
300
  });
235
301
 
236
302
  const allStepNames = steps.map((s) => s.name).filter(Boolean);
303
+ const orderIndex = new Map(steps.map((s, idx) => [String(s.id), idx]));
237
304
 
238
305
  const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
306
 
@@ -264,6 +331,26 @@ const buildGraph = (
264
331
  const loopBackLinks = findLoopBackLinks(steps);
265
332
  const loopBodyIds = findLoopBodyStepIds(steps);
266
333
 
334
+ // Track which handles are actualy used by edges per node
335
+ const handleUsage = {};
336
+ const markHandleUsage = (nodeId, handleId) => {
337
+ if (!nodeId || !handleId) return;
338
+ const key = String(nodeId);
339
+ if (!handleUsage[key]) handleUsage[key] = {};
340
+ handleUsage[key][handleId] = true;
341
+ };
342
+
343
+ // Track whether a node has forward/back edges to decide when to hide main bottom handle
344
+ const edgeStats = {};
345
+ const markEdgeKind = (nodeId, { back = false, forward = false } = {}) => {
346
+ if (!nodeId) return;
347
+ const key = String(nodeId);
348
+ if (!edgeStats[key])
349
+ edgeStats[key] = { hasBackEdge: false, hasForwardEdge: false };
350
+ if (back) edgeStats[key].hasBackEdge = true;
351
+ if (forward) edgeStats[key].hasForwardEdge = true;
352
+ };
353
+
267
354
  const adjacency = new Map();
268
355
  steps.forEach((s) => {
269
356
  const targets = [];
@@ -275,62 +362,33 @@ const buildGraph = (
275
362
  adjacency.set(String(s.id), [...new Set(targets)]);
276
363
  });
277
364
 
278
- const depths = {};
279
- const queue = [];
365
+ // Revising logical ordering based on workflow graph traversal from initial step
280
366
  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;
367
+ const visited = new Set();
368
+ const queue = [String(initial.id)];
369
+ let index = 0;
370
+ while (queue.length) {
371
+ const nodeId = queue.shift();
372
+ if (visited.has(nodeId)) continue;
373
+ visited.add(nodeId);
374
+ orderIndex.set(nodeId, index++);
375
+ const neighbors = adjacency.get(nodeId) || [];
376
+ neighbors.forEach((n) => {
377
+ if (!visited.has(n)) queue.push(n);
378
+ });
379
+ }
307
380
  }
308
381
 
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
382
  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
- });
383
+ let yCursor = 0;
384
+ steps.forEach((s) => {
385
+ const id = String(s.id);
386
+ const size = sizeById[id] || {
387
+ width: DEFAULT_NODE_WIDTH,
388
+ height: DEFAULT_NODE_HEIGHT,
389
+ };
390
+ positions[id] = { x: 0, y: yCursor };
391
+ yCursor += size.height + V_GAP;
334
392
  });
335
393
 
336
394
  // Nudge ForLoop bodies to be vertical under their loop parent for readability
@@ -378,10 +436,10 @@ const buildGraph = (
378
436
  startPosition ||
379
437
  (initial
380
438
  ? {
381
- x: positions[String(initial.id)]?.x - 180 || -180,
382
- y: positions[String(initial.id)]?.y || 40,
439
+ x: positions[String(initial.id)]?.x ?? 0,
440
+ y: (positions[String(initial.id)]?.y ?? 0) - 160,
383
441
  }
384
- : { x: -180, y: 40 });
442
+ : { x: 0, y: -160 });
385
443
 
386
444
  const nodes = [
387
445
  {
@@ -431,6 +489,7 @@ const buildGraph = (
431
489
  target: String(initial.id),
432
490
  type: "smoothstep",
433
491
  animated: true,
492
+ markerEnd: makeMarker(),
434
493
  });
435
494
  else {
436
495
  const addId = "add-start";
@@ -445,6 +504,7 @@ const buildGraph = (
445
504
  strokeDasharray: "4 2",
446
505
  stroke: "var(--wf-edge-adder, #0d6efd)",
447
506
  },
507
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
448
508
  });
449
509
  }
450
510
 
@@ -458,12 +518,42 @@ const buildGraph = (
458
518
  nextNames.forEach((name) => {
459
519
  const targetId = idByName[name];
460
520
  if (!targetId) return;
521
+ const srcOrder = orderIndex.get(String(step.id));
522
+ const tgtOrder = orderIndex.get(targetId);
523
+ const srcPos = nodePositions[String(step.id)];
524
+ const tgtPos = nodePositions[targetId];
525
+ const isBackByOrder =
526
+ srcOrder !== undefined &&
527
+ tgtOrder !== undefined &&
528
+ tgtOrder < srcOrder;
529
+ const isBackVisually =
530
+ srcPos &&
531
+ tgtPos &&
532
+ typeof srcPos.y === "number" &&
533
+ typeof tgtPos.y === "number"
534
+ ? tgtPos.y < srcPos.y
535
+ : false;
536
+ const isBackEdge = isBackByOrder && isBackVisually;
537
+ // record handle usage and edge direction for this source node
538
+ const sourceId = String(step.id);
539
+ if (isBackEdge) {
540
+ markHandleUsage(sourceId, "back-out");
541
+ markHandleUsage(targetId, "back-in");
542
+ markEdgeKind(sourceId, { back: true });
543
+ } else {
544
+ // uses the main bottom source handle
545
+ markHandleUsage(sourceId, "main");
546
+ markEdgeKind(sourceId, { forward: true });
547
+ }
461
548
  edges.push({
462
549
  id: `e-${step.id}-${name}`,
463
550
  source: String(step.id),
464
551
  target: targetId,
465
552
  type: "workflow-main",
466
553
  animated: true,
554
+ sourceHandle: isBackEdge ? "back-out" : undefined,
555
+ targetHandle: isBackEdge ? "back-in" : undefined,
556
+ markerEnd: makeMarker(),
467
557
  data: {
468
558
  startLabel: step.name,
469
559
  sourceStepName: step.name,
@@ -478,17 +568,25 @@ const buildGraph = (
478
568
  const loopTarget = step.configuration?.loop_body_initial_step;
479
569
  if (loopTarget) {
480
570
  const loopId = idByName[loopTarget];
571
+ const sourceId = String(step.id);
572
+ markHandleUsage(sourceId, "loop-out");
573
+ markEdgeKind(sourceId, { forward: true });
574
+ if (!loopId) {
575
+ markHandleUsage(sourceId, "loop-in");
576
+ }
481
577
  edges.push({
482
578
  id: `loop-${step.id}-${loopTarget}`,
483
579
  source: String(step.id),
484
580
  target: loopId || String(step.id),
485
581
  type: "smoothstep",
582
+ sourceHandle: "loop-out",
583
+ targetHandle: loopId ? undefined : "loop-in",
486
584
  style: {
487
585
  stroke: "var(--wf-edge-loop, #f59f00)",
488
586
  strokeDasharray: "6 4",
489
587
  },
490
588
  label: strings.loopBody,
491
- markerEnd: "arrowclosed",
589
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
492
590
  data: { loop: true, missing: !loopId },
493
591
  });
494
592
  }
@@ -498,17 +596,23 @@ const buildGraph = (
498
596
  const forLoopName = loopBackLinks[step.name];
499
597
  const loopId = idByName[forLoopName];
500
598
  if (loopId) {
599
+ const sourceId = String(step.id);
600
+ markHandleUsage(sourceId, "back-out");
601
+ markHandleUsage(loopId, "loop-in");
602
+ markEdgeKind(sourceId, { back: true });
501
603
  edges.push({
502
604
  id: `loopback-${step.id}-${forLoopName}`,
503
605
  source: String(step.id),
504
606
  target: loopId,
505
607
  type: "smoothstep",
608
+ sourceHandle: "back-out",
609
+ targetHandle: "loop-in",
506
610
  style: {
507
611
  stroke: "var(--wf-edge-loop, #f59f00)",
508
612
  strokeDasharray: "6 4",
509
613
  },
510
614
  data: { loop: true, loopBack: true },
511
- markerEnd: "arrowclosed",
615
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
512
616
  deletable: false,
513
617
  });
514
618
  }
@@ -522,8 +626,8 @@ const buildGraph = (
522
626
  id: addId,
523
627
  type: "add",
524
628
  position: addPositions?.[addId] || {
525
- x: startNodePosition.x + 160,
526
- y: startNodePosition.y,
629
+ x: startNodePosition.x,
630
+ y: startNodePosition.y + 160,
527
631
  },
528
632
  data: { strings, afterStepId: "start" },
529
633
  draggable: true,
@@ -544,8 +648,8 @@ const buildGraph = (
544
648
  id: addId,
545
649
  type: "add",
546
650
  position: addPositions?.[addId] || {
547
- x: basePos.x + baseSize.width + ADD_GAP,
548
- y: basePos.y + baseSize.height / 2 - ADD_NODE_SIZE / 2,
651
+ x: basePos.x + baseSize.width / 2 - ADD_NODE_SIZE / 2,
652
+ y: basePos.y + baseSize.height + ADD_GAP,
549
653
  },
550
654
  data: { strings, afterStepId: String(step.id) },
551
655
  draggable: true,
@@ -563,68 +667,110 @@ const buildGraph = (
563
667
  strokeDasharray: "4 2",
564
668
  stroke: "var(--wf-edge-adder, #0d6efd)",
565
669
  },
670
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
566
671
  });
567
672
  });
568
673
 
569
- const allNodes = [...nodes, ...addNodes];
674
+ const allNodes = [...nodes, ...addNodes].map((node) => {
675
+ if (node.type !== "step") return node;
676
+ const id = String(node.id);
677
+ const usage = handleUsage[id] || {};
678
+ const stats = edgeStats[id] || {
679
+ hasBackEdge: false,
680
+ hasForwardEdge: false,
681
+ };
682
+ const hideMainSource = stats.hasBackEdge && !stats.hasForwardEdge;
683
+
684
+ return {
685
+ ...node,
686
+ data: {
687
+ ...node.data,
688
+ showBackIn: !!usage["back-in"],
689
+ showBackOut: !!usage["back-out"],
690
+ showLoopIn: !!usage["loop-in"],
691
+ showLoopOut: !!usage["loop-out"],
692
+ hideMainSource,
693
+ },
694
+ };
695
+ });
570
696
 
571
697
  return { nodes: allNodes, edges, idByName, nameById };
572
698
  };
573
699
 
574
- const StepModal = ({
700
+ const StepDrawer = ({
575
701
  modal,
576
702
  innerRef,
703
+ outerRef,
577
704
  onClose,
578
705
  submitting,
579
706
  error,
580
707
  data,
581
708
  onDelete,
709
+ drawerWidth,
710
+ onResizeStart,
582
711
  }) => {
583
- if (!modal) return null;
712
+ const isOpen = !!modal;
584
713
  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>
714
+ <div
715
+ ref={outerRef}
716
+ className={`wf-drawer${isOpen ? " wf-drawer--open" : ""}`}
717
+ role="dialog"
718
+ aria-modal="false"
719
+ aria-hidden={!isOpen}
720
+ aria-label={modal?.title || data?.strings?.configure}
721
+ style={{ "--wf-drawer-width": `${drawerWidth}px` }}
722
+ >
723
+ {modal ? (
724
+ <>
725
+ <div className="wf-drawer__resize" onPointerDown={onResizeStart} />
726
+ <div className="wf-drawer__header d-flex justify-content-between align-items-center">
727
+ <div>
728
+ <h5 className="mb-0">{modal.title}</h5>
729
+ </div>
730
+ <button
731
+ className="btn-close"
732
+ onClick={onClose}
733
+ aria-label="Close"
734
+ />
735
+ </div>
736
+ <div className="wf-drawer__body">
737
+ <div
738
+ ref={innerRef}
739
+ dangerouslySetInnerHTML={{ __html: modal.body }}
740
+ />
741
+ {error ? (
742
+ <div className="alert alert-danger mt-3">{error}</div>
743
+ ) : null}
744
+ </div>
745
+ <div className="wf-drawer__footer d-flex justify-content-end gap-2">
746
+ <button
747
+ className="btn btn-sm btn-outline-danger"
748
+ onClick={(e) => {
749
+ e.stopPropagation();
750
+ onDelete(modal.stepId);
751
+ }}
752
+ >
753
+ {data.strings.deleteStep}
754
+ </button>
755
+ <button className="btn btn-secondary" onClick={onClose}>
756
+ Close
757
+ </button>
758
+ <button
759
+ className="btn btn-primary"
760
+ onClick={() =>
761
+ innerRef.current
762
+ ?.querySelector("form")
763
+ ?.dispatchEvent(
764
+ new Event("submit", { cancelable: true, bubbles: true })
765
+ )
766
+ }
767
+ disabled={submitting}
768
+ >
769
+ {submitting ? "Saving..." : "Save"}
770
+ </button>
771
+ </div>
772
+ </>
773
+ ) : null}
628
774
  </div>
629
775
  );
630
776
  };
@@ -649,19 +795,111 @@ const WorkflowEditor = ({ data }) => {
649
795
  const [savingModal, setSavingModal] = useState(false);
650
796
  const [savingPositions, setSavingPositions] = useState(false);
651
797
  const [sizeSyncing, setSizeSyncing] = useState(false);
798
+ const [drawerWidth, setDrawerWidth] = useState(() => {
799
+ if (typeof window !== "undefined") {
800
+ return Math.max(600, Math.round(window.innerWidth * 0.5));
801
+ }
802
+ return 600;
803
+ });
652
804
  const modalRef = useRef(null);
805
+ const drawerRef = useRef(null);
806
+ const shellRef = useRef(null);
807
+ const resizingRef = useRef(false);
808
+ const connectSourceRef = useRef(null);
809
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
810
+
811
+ const drawerStorageKey = useMemo(
812
+ () => `wf-drawer:${data?.trigger?.id || "default"}`,
813
+ [data?.trigger?.id]
814
+ );
815
+
816
+ const viewportStorageKey = useMemo(
817
+ () => `wf-viewport:${data?.trigger?.id || "default"}`,
818
+ [data?.trigger?.id]
819
+ );
653
820
 
654
821
  const rfInstanceRef = useRef(null);
655
822
 
823
+ const persistDrawerState = useCallback(
824
+ (payload) => {
825
+ try {
826
+ if (typeof window === "undefined") return;
827
+ window.sessionStorage.setItem(
828
+ drawerStorageKey,
829
+ JSON.stringify(payload || {})
830
+ );
831
+ } catch (e) {
832
+ // ignore storage errors
833
+ }
834
+ },
835
+ [drawerStorageKey]
836
+ );
837
+
838
+ const clearDrawerState = useCallback(() => {
839
+ try {
840
+ if (typeof window === "undefined") return;
841
+ window.sessionStorage.removeItem(drawerStorageKey);
842
+ } catch (e) {
843
+ // ignore storage errors
844
+ }
845
+ }, [drawerStorageKey]);
846
+
847
+ const loadViewport = useCallback(() => {
848
+ if (typeof window === "undefined") return null;
849
+ try {
850
+ const raw = window.sessionStorage.getItem(viewportStorageKey);
851
+ if (!raw) return null;
852
+ const parsed = JSON.parse(raw);
853
+ if (!parsed || typeof parsed !== "object") return null;
854
+ const { x, y, zoom } = parsed;
855
+ if (
856
+ Number.isFinite(Number(x)) &&
857
+ Number.isFinite(Number(y)) &&
858
+ Number.isFinite(Number(zoom))
859
+ ) {
860
+ return { x: Number(x), y: Number(y), zoom: Number(zoom) };
861
+ }
862
+ } catch (e) {
863
+ // ignore storage errors
864
+ }
865
+ return null;
866
+ }, [viewportStorageKey]);
867
+
868
+ const persistViewport = useCallback(
869
+ (viewport) => {
870
+ if (!viewport) return;
871
+ const { x, y, zoom } = viewport;
872
+ if (
873
+ !Number.isFinite(Number(x)) ||
874
+ !Number.isFinite(Number(y)) ||
875
+ !Number.isFinite(Number(zoom))
876
+ )
877
+ return;
878
+ try {
879
+ if (typeof window === "undefined") return;
880
+ window.sessionStorage.setItem(
881
+ viewportStorageKey,
882
+ JSON.stringify({ x: Number(x), y: Number(y), zoom: Number(zoom) })
883
+ );
884
+ } catch (e) {
885
+ // ignore storage errors
886
+ }
887
+ },
888
+ [viewportStorageKey]
889
+ );
890
+
656
891
  const onInit = useCallback((instance) => {
657
892
  rfInstanceRef.current = instance;
658
893
  }, []);
659
894
 
660
895
  const [selectedNodes, setSelectedNodes] = useState([]);
896
+ const pendingViewportRef = useRef(loadViewport());
897
+ const appliedViewportRef = useRef(false);
661
898
 
662
- const handleSelectionChange = useCallback(({ nodes = [] }) => {
663
- console.log("selection change", nodes);
899
+ const handleSelectionChange = useCallback(({ nodes = [], edges = [] }) => {
664
900
  setSelectedNodes(nodes);
901
+ if (edges.length) setSelectedEdgeId(edges[0].id);
902
+ else setSelectedEdgeId(null);
665
903
  }, []);
666
904
  const handleKeyDelete = useCallback(
667
905
  (e) => {
@@ -715,6 +953,16 @@ const WorkflowEditor = ({ data }) => {
715
953
  refreshGraph(steps);
716
954
  }, [steps, refreshGraph]);
717
955
 
956
+ useEffect(() => {
957
+ if (!rfInstanceRef.current) return;
958
+ if (!pendingViewportRef.current) return;
959
+ if (appliedViewportRef.current) return;
960
+ rfInstanceRef.current.setViewport(pendingViewportRef.current, {
961
+ duration: 0,
962
+ });
963
+ appliedViewportRef.current = true;
964
+ }, [nodes]);
965
+
718
966
  useEffect(() => {
719
967
  window.addEventListener("keydown", handleKeyDelete);
720
968
  return () => window.removeEventListener("keydown", handleKeyDelete);
@@ -744,10 +992,12 @@ const WorkflowEditor = ({ data }) => {
744
992
  const stepNode = stepById.get(node.id.replace("add-", ""));
745
993
  if (!stepNode) return;
746
994
 
747
- const nextX = stepNode.position.x + stepNode.width + ADD_GAP;
995
+ const nextX =
996
+ stepNode.position.x +
997
+ stepNode.width / 2 -
998
+ (node.width || ADD_NODE_SIZE) / 2;
748
999
 
749
- const nextY =
750
- stepNode.position.y + stepNode.height / 2 - (node.height || 24) / 2;
1000
+ const nextY = stepNode.position.y + stepNode.height + ADD_GAP;
751
1001
 
752
1002
  const nextPosition = { x: nextX, y: nextY };
753
1003
 
@@ -817,7 +1067,6 @@ const WorkflowEditor = ({ data }) => {
817
1067
 
818
1068
  const reload = useCallback(async () => {
819
1069
  setLoading(true);
820
- setModal(null);
821
1070
  setError("");
822
1071
  try {
823
1072
  let fresh = await fetchJson(data.urls.data);
@@ -962,6 +1211,12 @@ const WorkflowEditor = ({ data }) => {
962
1211
  async ({ stepId, initial_step, after_step } = {}) => {
963
1212
  try {
964
1213
  setError("");
1214
+ const descriptor = {
1215
+ stepId: stepId || null,
1216
+ initial_step: !!initial_step,
1217
+ after_step: after_step || null,
1218
+ };
1219
+ persistDrawerState(descriptor);
965
1220
  const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
966
1221
  const url = new URL(base, window.location.origin);
967
1222
  url.searchParams.set("render", "dialog");
@@ -970,10 +1225,11 @@ const WorkflowEditor = ({ data }) => {
970
1225
  const res = await fetchJson(url.toString());
971
1226
  setModal({ title: res.title, body: res.form, stepId: stepId || null });
972
1227
  } catch (e) {
1228
+ clearDrawerState();
973
1229
  setError(e.message);
974
1230
  }
975
1231
  },
976
- [data.urls.stepForm, fetchJson]
1232
+ [clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
977
1233
  );
978
1234
 
979
1235
  const handleModalSubmit = useCallback(
@@ -994,9 +1250,10 @@ const WorkflowEditor = ({ data }) => {
994
1250
  const json = await res.json();
995
1251
  if (json.error) throw new Error(json.error);
996
1252
  setModal(null);
1253
+ clearDrawerState();
997
1254
  await reload();
998
1255
  },
999
- [data.csrfToken, data.urls.stepForm, reload]
1256
+ [clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
1000
1257
  );
1001
1258
 
1002
1259
  useEffect(() => {
@@ -1005,8 +1262,11 @@ const WorkflowEditor = ({ data }) => {
1005
1262
  if (!formEl) return undefined;
1006
1263
  // Re-run show-if logic so action-specific fields render correctly
1007
1264
  setTimeout(() => {
1008
- if (typeof window !== "undefined" && window.apply_showif)
1009
- window.apply_showif();
1265
+ if (typeof window !== "undefined") {
1266
+ if (window.apply_showif) window.apply_showif();
1267
+ // Initialize Monaco and any other dynamic widgets inside the drawer
1268
+ if (window.initialize_page) window.initialize_page();
1269
+ }
1010
1270
  }, 0);
1011
1271
 
1012
1272
  const actionSelect = formEl.querySelector('[name="wf_action_name"]');
@@ -1035,6 +1295,19 @@ const WorkflowEditor = ({ data }) => {
1035
1295
  };
1036
1296
  }, [handleModalSubmit, modal]);
1037
1297
 
1298
+ useEffect(() => {
1299
+ if (typeof window === "undefined") return undefined;
1300
+ const raw = window.sessionStorage.getItem(drawerStorageKey);
1301
+ if (!raw) return undefined;
1302
+ try {
1303
+ const parsed = JSON.parse(raw);
1304
+ if (parsed && typeof parsed === "object") openStepForm(parsed);
1305
+ } catch (e) {
1306
+ clearDrawerState();
1307
+ }
1308
+ return undefined;
1309
+ }, [clearDrawerState, drawerStorageKey, openStepForm]);
1310
+
1038
1311
  const updateConnection = useCallback(
1039
1312
  async (payload) => {
1040
1313
  const body = new URLSearchParams();
@@ -1054,6 +1327,9 @@ const WorkflowEditor = ({ data }) => {
1054
1327
 
1055
1328
  const onConnect = useCallback(
1056
1329
  async (connection) => {
1330
+ // Valid connection landed; clear pending source so we don't treat it as a cancel
1331
+ connectSourceRef.current = null;
1332
+ setSelectedEdgeId(connection.id || null);
1057
1333
  const loop = connection.sourceHandle === "loop";
1058
1334
  const targetName = nameById[connection.target];
1059
1335
  if (connection.source === "start") {
@@ -1074,12 +1350,24 @@ const WorkflowEditor = ({ data }) => {
1074
1350
  const srcStep = steps.find(
1075
1351
  (s) => String(s.id) === String(connection.source)
1076
1352
  );
1353
+ const tgtStep = steps.find(
1354
+ (s) => String(s.id) === String(connection.target)
1355
+ );
1077
1356
  const rawNext = srcStep?.next_step
1078
1357
  ? String(srcStep.next_step).trim()
1079
1358
  : "";
1080
1359
  const simpleNextId = rawNext && idByName[rawNext];
1360
+ const targetHasNext = !!(
1361
+ tgtStep &&
1362
+ tgtStep.next_step &&
1363
+ String(tgtStep.next_step).trim()
1364
+ );
1081
1365
 
1082
- if (simpleNextId && simpleNextId !== connection.target) {
1366
+ if (
1367
+ simpleNextId &&
1368
+ simpleNextId !== connection.target &&
1369
+ !targetHasNext
1370
+ ) {
1083
1371
  // Rewire: source -> new target, new target -> old target name
1084
1372
  await updateConnection({
1085
1373
  step_id: connection.target,
@@ -1097,6 +1385,28 @@ const WorkflowEditor = ({ data }) => {
1097
1385
  [idByName, nameById, reload, steps, updateConnection]
1098
1386
  );
1099
1387
 
1388
+ const onConnectStart = useCallback((_, params) => {
1389
+ if (params?.nodeId && params?.handleType === "source") {
1390
+ connectSourceRef.current = params;
1391
+ }
1392
+ }, []);
1393
+
1394
+ const onConnectEnd = useCallback(
1395
+ (event) => {
1396
+ const pending = connectSourceRef.current;
1397
+ connectSourceRef.current = null;
1398
+ if (!pending) return;
1399
+ const targetEl = event?.target;
1400
+ if (targetEl?.classList?.contains("react-flow__pane")) {
1401
+ // Dropped on empty space: clear next_step mark as final/unconnected
1402
+ if (pending.nodeId && pending.handleId !== "loop-out") {
1403
+ onClearNext(pending.nodeId);
1404
+ }
1405
+ }
1406
+ },
1407
+ [onClearNext]
1408
+ );
1409
+
1100
1410
  const onEdgesChange = useCallback(
1101
1411
  (changes) => {
1102
1412
  const removed = changes.filter((c) => c.type === "remove");
@@ -1216,15 +1526,22 @@ const WorkflowEditor = ({ data }) => {
1216
1526
  const onDelete = useCallback(
1217
1527
  async (id) => {
1218
1528
  if (!window.confirm(strings.confirmDelete)) return;
1219
- const formData = new FormData();
1220
- formData.append("_csrf", data.csrfToken || "");
1221
- await fetchJson(`${data.urls.deleteStep}/${id}`, {
1222
- method: "POST",
1223
- body: formData,
1224
- });
1225
- await reload();
1529
+ try {
1530
+ const formData = new FormData();
1531
+ formData.append("_csrf", data.csrfToken || "");
1532
+ await fetchJson(`${data.urls.deleteStep}/${id}`, {
1533
+ method: "POST",
1534
+ body: formData,
1535
+ });
1536
+ setModal(null);
1537
+ setError("");
1538
+ clearDrawerState();
1539
+ await reload();
1540
+ } catch (e) {
1541
+ setError(e.message);
1542
+ }
1226
1543
  },
1227
- [data.csrfToken, fetchJson, reload, strings.confirmDelete]
1544
+ [clearDrawerState, data.csrfToken, fetchJson, reload, strings.confirmDelete]
1228
1545
  );
1229
1546
 
1230
1547
  useEffect(() => {
@@ -1289,9 +1606,106 @@ const WorkflowEditor = ({ data }) => {
1289
1606
  }, [onInsertBetween, setEdges, edges]);
1290
1607
 
1291
1608
  const hasSteps = steps.length > 0;
1609
+ const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
1610
+ const shellStyle = { "--wf-drawer-width": `${drawerWidth}px` };
1611
+ const showMiniMap = !modal;
1612
+ const shouldFitView = !pendingViewportRef.current;
1613
+
1614
+ const onEdgeClick = useCallback((_, edge) => {
1615
+ setSelectedEdgeId(edge.id);
1616
+ }, []);
1617
+
1618
+ useEffect(() => {
1619
+ setEdges((edgs) =>
1620
+ edgs.map((e) => {
1621
+ const selected = e.id === selectedEdgeId;
1622
+ const nextClass = selected
1623
+ ? `${e.className || ""} wf-edge-selected`.trim()
1624
+ : (e.className || "").replace(/\s*wf-edge-selected\b/, "");
1625
+ const nextData =
1626
+ e.type === "workflow-main" ? { ...(e.data || {}), selected } : e.data;
1627
+ if (nextClass === (e.className || "") && nextData === e.data) return e;
1628
+ return { ...e, className: nextClass, data: nextData };
1629
+ })
1630
+ );
1631
+ }, [selectedEdgeId, setEdges]);
1632
+
1633
+ const handleResize = useCallback((e) => {
1634
+ if (!resizingRef.current) return;
1635
+ const maxWidth = Math.min(900, Math.max(360, window.innerWidth - 40));
1636
+ const nextWidth = Math.min(
1637
+ Math.max(600, window.innerWidth - e.clientX),
1638
+ maxWidth
1639
+ );
1640
+ setDrawerWidth(nextWidth);
1641
+ }, []);
1642
+
1643
+ const stopResize = useCallback(() => {
1644
+ if (!resizingRef.current) return;
1645
+ resizingRef.current = false;
1646
+ document.body.style.userSelect = "";
1647
+ window.removeEventListener("pointermove", handleResize);
1648
+ window.removeEventListener("pointerup", stopResize);
1649
+ }, [handleResize]);
1650
+
1651
+ const startResize = useCallback(
1652
+ (e) => {
1653
+ if (!modal) return;
1654
+ resizingRef.current = true;
1655
+ document.body.style.userSelect = "none";
1656
+ window.addEventListener("pointermove", handleResize);
1657
+ window.addEventListener("pointerup", stopResize);
1658
+ e.preventDefault();
1659
+ },
1660
+ [handleResize, modal, stopResize]
1661
+ );
1662
+
1663
+ useEffect(() => () => stopResize(), [stopResize]);
1664
+
1665
+ const closeModal = useCallback(() => {
1666
+ setModal(null);
1667
+ setError("");
1668
+ clearDrawerState();
1669
+ }, [clearDrawerState]);
1670
+
1671
+ useLayoutEffect(() => {
1672
+ const shellEl = shellRef.current;
1673
+ const drawerEl = drawerRef.current;
1674
+ if (!shellEl) return undefined;
1675
+ const hasResizeObserver = typeof ResizeObserver !== "undefined";
1676
+
1677
+ const computePadding = () => {
1678
+ if (!drawerEl || !modal) {
1679
+ shellEl.style.removeProperty("--wf-shell-padding-right");
1680
+ return;
1681
+ }
1682
+ const shellRect = shellEl.getBoundingClientRect();
1683
+ const drawerRect = drawerEl.getBoundingClientRect();
1684
+ // Space already available to the right of the shell (e.g. due to sidebars)
1685
+ const naturalGap = Math.max(0, window.innerWidth - shellRect.right);
1686
+ const needed = Math.max(0, drawerRect.width - naturalGap) + 4;
1687
+ shellEl.style.setProperty("--wf-shell-padding-right", `${needed}px`);
1688
+ };
1689
+
1690
+ computePadding();
1691
+
1692
+ const resizeObserver = hasResizeObserver
1693
+ ? new ResizeObserver(() => computePadding())
1694
+ : null;
1695
+ if (resizeObserver) {
1696
+ resizeObserver.observe(shellEl);
1697
+ if (drawerEl) resizeObserver.observe(drawerEl);
1698
+ }
1699
+ window.addEventListener("resize", computePadding);
1700
+
1701
+ return () => {
1702
+ if (resizeObserver) resizeObserver.disconnect();
1703
+ window.removeEventListener("resize", computePadding);
1704
+ };
1705
+ }, [drawerWidth, modal]);
1292
1706
 
1293
1707
  return (
1294
- <div className="wf-shell">
1708
+ <div className={shellClass} style={shellStyle} ref={shellRef}>
1295
1709
  <div className="wf-toolbar">
1296
1710
  <div className="wf-toolbar__left">
1297
1711
  <strong>{strings.configure}</strong>
@@ -1335,31 +1749,36 @@ const WorkflowEditor = ({ data }) => {
1335
1749
  onNodesChange={onNodesChangeWrapped}
1336
1750
  onEdgesChange={onEdgesChange}
1337
1751
  onConnect={onConnect}
1752
+ onConnectStart={onConnectStart}
1753
+ onConnectEnd={onConnectEnd}
1754
+ onEdgeClick={onEdgeClick}
1338
1755
  onSelectionChange={handleSelectionChange}
1339
1756
  nodeTypes={nodeTypes}
1340
1757
  edgeTypes={edgeTypes}
1341
1758
  onInit={onInit}
1342
- fitView
1759
+ defaultViewport={pendingViewportRef.current || undefined}
1760
+ fitView={shouldFitView}
1343
1761
  proOptions={{ hideAttribution: true }}
1344
1762
  defaultEdgeOptions={{ animated: true }}
1345
1763
  colorMode={window._sc_lightmode || "light"}
1764
+ onMoveEnd={(_, viewport) => persistViewport(viewport)}
1346
1765
  >
1347
- <MiniMap pannable zoomable />
1766
+ {showMiniMap && <MiniMap pannable zoomable />}
1348
1767
  <Controls />
1349
1768
  <Background gap={16} />
1350
1769
  </ReactFlow>
1351
1770
  </div>
1352
- <StepModal
1771
+ <StepDrawer
1353
1772
  modal={modal}
1354
1773
  innerRef={modalRef}
1355
- onClose={() => {
1356
- setModal(null);
1357
- setError("");
1358
- }}
1774
+ outerRef={drawerRef}
1775
+ onClose={closeModal}
1359
1776
  submitting={savingModal}
1360
1777
  error={error && modal ? error : ""}
1361
1778
  data={data}
1362
1779
  onDelete={onDelete}
1780
+ drawerWidth={drawerWidth}
1781
+ onResizeStart={startResize}
1363
1782
  />
1364
1783
  </div>
1365
1784
  );