@nyaruka/temba-components 0.138.4 → 0.139.0

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 (69) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/locales/es.js +5 -5
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +5 -5
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/locale-codes.js +2 -11
  7. package/dist/locales/locale-codes.js.map +1 -1
  8. package/dist/locales/pt.js +5 -5
  9. package/dist/locales/pt.js.map +1 -1
  10. package/dist/temba-components.js +816 -852
  11. package/dist/temba-components.js.map +1 -1
  12. package/out-tsc/src/display/FloatingTab.js +23 -30
  13. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  14. package/out-tsc/src/flow/CanvasMenu.js +5 -3
  15. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  16. package/out-tsc/src/flow/CanvasNode.js +6 -7
  17. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  18. package/out-tsc/src/flow/Editor.js +152 -235
  19. package/out-tsc/src/flow/Editor.js.map +1 -1
  20. package/out-tsc/src/flow/Plumber.js +757 -403
  21. package/out-tsc/src/flow/Plumber.js.map +1 -1
  22. package/out-tsc/src/flow/utils.js +138 -66
  23. package/out-tsc/src/flow/utils.js.map +1 -1
  24. package/out-tsc/src/interfaces.js +1 -0
  25. package/out-tsc/src/interfaces.js.map +1 -1
  26. package/out-tsc/src/list/TicketList.js +4 -1
  27. package/out-tsc/src/list/TicketList.js.map +1 -1
  28. package/out-tsc/src/live/ContactChat.js +18 -1
  29. package/out-tsc/src/live/ContactChat.js.map +1 -1
  30. package/out-tsc/src/locales/es.js +5 -5
  31. package/out-tsc/src/locales/es.js.map +1 -1
  32. package/out-tsc/src/locales/fr.js +5 -5
  33. package/out-tsc/src/locales/fr.js.map +1 -1
  34. package/out-tsc/src/locales/locale-codes.js +2 -11
  35. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  36. package/out-tsc/src/locales/pt.js +5 -5
  37. package/out-tsc/src/locales/pt.js.map +1 -1
  38. package/out-tsc/src/simulator/Simulator.js +1 -0
  39. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  40. package/out-tsc/test/temba-floating-tab.test.js +4 -6
  41. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  42. package/out-tsc/test/temba-flow-collision.test.js +221 -223
  43. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  44. package/out-tsc/test/temba-flow-editor.test.js +0 -2
  45. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  46. package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
  47. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  48. package/out-tsc/test/temba-flow-plumber.test.js +102 -93
  49. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/display/FloatingTab.ts +22 -31
  52. package/src/flow/CanvasMenu.ts +8 -3
  53. package/src/flow/CanvasNode.ts +6 -7
  54. package/src/flow/Editor.ts +184 -279
  55. package/src/flow/Plumber.ts +1011 -457
  56. package/src/flow/utils.ts +162 -84
  57. package/src/interfaces.ts +2 -1
  58. package/src/list/TicketList.ts +4 -1
  59. package/src/live/ContactChat.ts +19 -1
  60. package/src/locales/es.ts +13 -18
  61. package/src/locales/fr.ts +13 -18
  62. package/src/locales/locale-codes.ts +2 -11
  63. package/src/locales/pt.ts +13 -18
  64. package/src/simulator/Simulator.ts +1 -0
  65. package/test/temba-floating-tab.test.ts +4 -6
  66. package/test/temba-flow-collision.test.ts +225 -303
  67. package/test/temba-flow-editor.test.ts +0 -2
  68. package/test/temba-flow-plumber-connections.test.ts +97 -97
  69. package/test/temba-flow-plumber.test.ts +116 -103
@@ -37,25 +37,26 @@ interface Revision {
37
37
  import { ACTION_GROUP_METADATA } from './types';
38
38
  import { Checkbox } from '../form/Checkbox';
39
39
 
40
- import { Plumber } from './Plumber';
40
+ import {
41
+ Plumber,
42
+ calculateFlowchartPath,
43
+ ARROW_LENGTH,
44
+ ARROW_HALF_WIDTH,
45
+ CURSOR_GAP
46
+ } from './Plumber';
41
47
  import { CanvasNode } from './CanvasNode';
42
48
  import { Dialog } from '../layout/Dialog';
43
- import { Connection } from '@jsplumb/browser-ui';
49
+
44
50
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
45
51
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
46
52
  import {
47
53
  getNodeBounds,
48
54
  calculateReflowPositions,
49
55
  NodeBounds,
50
- nodesOverlap
56
+ snapToGrid
51
57
  } from './utils';
52
58
  import { FloatingWindow } from '../layout/FloatingWindow';
53
59
 
54
- export function snapToGrid(value: number): number {
55
- const snapped = Math.round(value / 20) * 20;
56
- return Math.max(snapped, 0);
57
- }
58
-
59
60
  export function findNodeForExit(
60
61
  definition: FlowDefinition,
61
62
  exitUuid: string
@@ -122,7 +123,7 @@ const DROP_PREVIEW_OFFSET_X = 20;
122
123
  const DROP_PREVIEW_OFFSET_Y = 20;
123
124
 
124
125
  export class Editor extends RapidElement {
125
- // unfortunately, jsplumb requires that we be in light DOM
126
+ // connection SVGs are appended directly to the canvas, so we need light DOM
126
127
  createRenderRoot() {
127
128
  return this;
128
129
  }
@@ -212,6 +213,10 @@ export class Editor extends RapidElement {
212
213
  @state()
213
214
  private isValidTarget = true;
214
215
 
216
+ // Canvas-relative source exit position (set at drag start)
217
+ private connectionSourceX: number | null = null;
218
+ private connectionSourceY: number | null = null;
219
+
215
220
  @state()
216
221
  private localizationWindowHidden = true;
217
222
 
@@ -294,6 +299,7 @@ export class Editor extends RapidElement {
294
299
  private connectionPlaceholder: {
295
300
  position: FlowPosition;
296
301
  visible: boolean;
302
+ dragUp?: boolean;
297
303
  } | null = null;
298
304
 
299
305
  // Track pending connection when dropping on canvas
@@ -401,100 +407,56 @@ export class Editor extends RapidElement {
401
407
  }
402
408
 
403
409
  #grid.viewing-revision temba-flow-node,
404
- #grid.viewing-revision svg.jtk-connector,
405
- #grid.viewing-revision .activity-overlay {
410
+ #grid.viewing-revision svg.plumb-connector {
406
411
  opacity: 0.5;
407
412
  }
408
413
 
409
- body .jtk-endpoint {
410
- width: initial;
411
- height: initial;
412
- }
413
-
414
- .jtk-endpoint {
415
- z-index: 600;
416
- opacity: 0;
417
- }
418
-
419
- .plumb-source {
420
- z-index: 600;
421
- cursor: pointer;
422
- opacity: 0;
423
- }
424
-
425
- .plumb-source.connected {
426
- border-radius: 50%;
427
- pointer-events: none;
414
+ svg.plumb-connector {
415
+ z-index: 10;
428
416
  }
429
417
 
430
- .plumb-source circle {
431
- fill: purple;
418
+ svg.plumb-connector path {
419
+ stroke: var(--color-connectors);
420
+ stroke-width: 3px;
432
421
  }
433
422
 
434
- .plumb-target {
435
- z-index: 600;
436
- opacity: 0;
437
- cursor: pointer;
438
- fill: transparent;
423
+ svg.plumb-connector .plumb-arrow {
424
+ fill: var(--color-connectors);
425
+ stroke: none;
439
426
  }
440
427
 
441
- body svg.jtk-connector.plumb-connector path {
442
- stroke: var(--color-connectors) !important;
443
- stroke-width: 3px;
428
+ svg.plumb-connector.hover path {
429
+ stroke: var(--color-success);
444
430
  }
445
431
 
446
- body .plumb-connector {
447
- z-index: 10 !important;
432
+ svg.plumb-connector.hover .plumb-arrow {
433
+ fill: var(--color-success);
448
434
  }
449
435
 
450
- body .plumb-connector .plumb-arrow {
451
- fill: var(--color-connectors);
436
+ #canvas.read-only-connections svg.plumb-connector.hover path {
452
437
  stroke: var(--color-connectors);
453
- stroke-width: 0px !important;
454
- margin-top: 6px;
455
- z-index: 10;
456
- }
457
-
458
- body svg.jtk-connector.jtk-hover path {
459
- stroke: var(--color-success) !important;
460
- stroke-width: 3px;
461
438
  }
462
439
 
463
- body #canvas.read-only-connections svg.jtk-connector.jtk-hover path {
464
- stroke: var(--color-connectors) !important;
440
+ #canvas.read-only-connections svg.plumb-connector.hover .plumb-arrow {
441
+ fill: var(--color-connectors);
465
442
  }
466
443
 
467
- body .plumb-connector.jtk-hover .plumb-arrow {
468
- fill: var(--color-success) !important;
469
- stroke-width: 0px;
470
- z-index: 10;
444
+ #canvas.read-only-connections svg.plumb-connector,
445
+ #canvas.read-only-connections svg.plumb-connector * {
446
+ pointer-events: none !important;
447
+ cursor: default !important;
471
448
  }
472
449
 
473
- body
474
- #canvas.read-only-connections
475
- .plumb-connector.jtk-hover
476
- .plumb-arrow {
477
- fill: var(--color-connectors) !important;
478
- ponter-events: none;
450
+ svg.plumb-connector.removing path {
451
+ stroke: var(--color-error);
479
452
  }
480
453
 
481
- body #canvas.read-only-connections svg {
482
- pointer-events: none;
454
+ svg.plumb-connector.removing .plumb-arrow {
455
+ fill: var(--color-error);
483
456
  }
484
457
 
485
- /* Activity overlays on connections */
486
- .jtk-overlay.activity-overlay {
487
- background: #f3f3f3;
488
- border: 1px solid #d9d9d9;
489
- color: #333;
490
- border-radius: 4px;
491
- padding: 2px 4px;
492
- font-size: 10px;
493
- font-weight: 600;
494
- line-height: 0.9;
495
- cursor: pointer;
496
- z-index: 500;
497
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
458
+ svg.plumb-connector.dragging {
459
+ z-index: 99999;
498
460
  }
499
461
 
500
462
  /* Active contact count on nodes */
@@ -516,6 +478,30 @@ export class Editor extends RapidElement {
516
478
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
517
479
  }
518
480
 
481
+ /* Activity overlay badges on connection exit stubs */
482
+ .activity-overlay {
483
+ position: absolute;
484
+ background: #f3f3f3;
485
+ border: 1px solid #d9d9d9;
486
+ color: #333;
487
+ border-radius: 4px;
488
+ padding: 2px 4px;
489
+ font-size: 10px;
490
+ font-weight: 600;
491
+ line-height: 0.9;
492
+ cursor: pointer;
493
+ z-index: 500;
494
+ pointer-events: auto;
495
+ white-space: nowrap;
496
+ user-select: none;
497
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
498
+ }
499
+
500
+ #grid.viewing-revision .activity-overlay {
501
+ opacity: 0.5;
502
+ pointer-events: none;
503
+ }
504
+
519
505
  /* Recent contacts popup */
520
506
  @keyframes popupBounceIn {
521
507
  0% {
@@ -585,7 +571,6 @@ export class Editor extends RapidElement {
585
571
 
586
572
  .recent-contacts-popup .contact-name:hover {
587
573
  text-decoration: underline;
588
- color: var(--color-link-primary, #1d4ed8);
589
574
  }
590
575
 
591
576
  .recent-contacts-popup .contact-operand {
@@ -601,17 +586,6 @@ export class Editor extends RapidElement {
601
586
  color: #999;
602
587
  }
603
588
 
604
- /* Connection dragging feedback */
605
- body svg.jtk-connector.jtk-dragging {
606
- z-index: 99999 !important;
607
- }
608
-
609
- .katavorio-drag-no-select svg.jtk-connector path,
610
- .katavorio-drag-no-select svg.jtk-endpoint path {
611
- pointer-events: none !important;
612
- border: 1px solid purple;
613
- }
614
-
615
589
  /* Connection target feedback */
616
590
  temba-flow-node.connection-target-valid {
617
591
  outline: 3px solid var(--color-success, #22c55e) !important;
@@ -641,10 +615,6 @@ export class Editor extends RapidElement {
641
615
  border-radius: var(--curvature);
642
616
  }
643
617
 
644
- .jtk-floating-endpoint {
645
- pointer-events: none;
646
- }
647
-
648
618
  .localization-window-content {
649
619
  display: flex;
650
620
  flex-direction: column;
@@ -852,17 +822,15 @@ export class Editor extends RapidElement {
852
822
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
853
823
  }
854
824
 
855
- this.plumber.on('connection:drag', (connection: Connection) => {
856
- // console.log('connection:drag', connection);
857
- this.dragFromNodeId =
858
- connection.data.nodeId ||
859
- document.getElementById(connection.sourceId).closest('.node').id;
825
+ this.plumber.on('connection:drag', (connection: any) => {
826
+ this.dragFromNodeId = connection.data.nodeId;
860
827
  this.sourceId = connection.sourceId;
828
+ this.connectionSourceX = connection.sourceX;
829
+ this.connectionSourceY = connection.sourceY;
861
830
  this.originalConnectionTargetId = connection.target.id;
862
831
  });
863
832
 
864
833
  this.plumber.on('connection:abort', (info) => {
865
- // console.log('Connection aborted', info);
866
834
  this.makeConnection(info);
867
835
  });
868
836
 
@@ -898,6 +866,7 @@ export class Editor extends RapidElement {
898
866
  left: snapToGrid(this.connectionPlaceholder.position.left),
899
867
  top: snapToGrid(this.connectionPlaceholder.position.top)
900
868
  };
869
+ const isDragUp = !!this.connectionPlaceholder.dragUp;
901
870
 
902
871
  // Update the placeholder to the snapped position
903
872
  this.connectionPlaceholder.position = snappedPosition;
@@ -909,12 +878,14 @@ export class Editor extends RapidElement {
909
878
  position: snappedPosition
910
879
  };
911
880
 
912
- // Show the context menu just below the placeholder
881
+ // Show the context menu near the placeholder
913
882
  const canvas = this.querySelector('#canvas');
914
883
  if (canvas) {
915
884
  const canvasRect = canvas.getBoundingClientRect();
916
- const menuX = canvasRect.left + snappedPosition.left - 40; // center horizontally
917
- const menuY = canvasRect.top + snappedPosition.top + 80; // just below placeholder
885
+ const menuX = canvasRect.left + snappedPosition.left - 40;
886
+ const menuY = isDragUp
887
+ ? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
888
+ : canvasRect.top + snappedPosition.top + 80; // just below placeholder
918
889
 
919
890
  const canvasMenu = this.querySelector(
920
891
  'temba-canvas-menu'
@@ -950,6 +921,8 @@ export class Editor extends RapidElement {
950
921
  // Clear connection state (but keep sourceId/dragFromNodeId if we have a pending connection)
951
922
  if (!this.pendingCanvasConnection) {
952
923
  this.sourceId = null;
924
+ this.connectionSourceX = null;
925
+ this.connectionSourceY = null;
953
926
  this.dragFromNodeId = null;
954
927
  }
955
928
  this.targetId = null;
@@ -1130,7 +1103,7 @@ export class Editor extends RapidElement {
1130
1103
  }
1131
1104
 
1132
1105
  this.activityTimer = window.setTimeout(() => {
1133
- // this.fetchActivityData();
1106
+ this.fetchActivityData();
1134
1107
  }, this.activityInterval);
1135
1108
  });
1136
1109
  }
@@ -1570,86 +1543,71 @@ export class Editor extends RapidElement {
1570
1543
  if (!this.connectionPlaceholder || !this.connectionPlaceholder.visible)
1571
1544
  return '';
1572
1545
 
1573
- const { position } = this.connectionPlaceholder;
1546
+ const { position, dragUp } = this.connectionPlaceholder;
1574
1547
 
1575
1548
  // Render connection line when we have a pending connection (after drop)
1576
1549
  let svgPath = null;
1577
- if (this.sourceId && this.dragFromNodeId && this.pendingCanvasConnection) {
1578
- const sourceElement = document.getElementById(this.sourceId);
1579
- if (sourceElement) {
1580
- const sourceRect = sourceElement.getBoundingClientRect();
1581
- const canvas = this.querySelector('#canvas');
1582
- const canvasRect = canvas.getBoundingClientRect();
1583
-
1584
- // Source point (bottom center of exit)
1585
- const sourceX =
1586
- sourceRect.left + sourceRect.width / 2 - canvasRect.left;
1587
- const sourceY = sourceRect.bottom - canvasRect.top;
1588
-
1589
- // Target point (top center of placeholder)
1590
- const targetX = position.left + 100; // 100 is half the placeholder width (200px)
1591
- const targetY = position.top;
1592
-
1593
- // Use jsPlumb FlowchartConnector parameters: stub [20, 10], cornerRadius 5
1594
- const stubStart = 20;
1595
- const stubEnd = 10;
1596
- const cornerRadius = 5;
1597
-
1598
- // Calculate flowchart path with corners
1599
- const verticalStart = sourceY + stubStart;
1600
- const verticalEnd = targetY - stubEnd;
1601
- const midY = (verticalStart + verticalEnd) / 2;
1602
-
1603
- // Build path with rounded corners (flowchart style)
1604
- let pathData = `M ${sourceX} ${sourceY} L ${sourceX} ${verticalStart}`;
1605
-
1606
- if (sourceX !== targetX) {
1607
- // Horizontal segment needed
1608
- if (Math.abs(verticalEnd - verticalStart) > cornerRadius * 2) {
1609
- // Enough space for corners
1610
- pathData += ` L ${sourceX} ${midY - cornerRadius}`;
1611
- pathData += ` Q ${sourceX} ${midY}, ${
1612
- sourceX + (targetX > sourceX ? cornerRadius : -cornerRadius)
1613
- } ${midY}`;
1614
- pathData += ` L ${
1615
- targetX - (targetX > sourceX ? cornerRadius : -cornerRadius)
1616
- } ${midY}`;
1617
- pathData += ` Q ${targetX} ${midY}, ${targetX} ${
1618
- midY + cornerRadius
1619
- }`;
1620
- pathData += ` L ${targetX} ${verticalEnd}`;
1621
- } else {
1622
- // Direct horizontal transition
1623
- pathData += ` L ${targetX} ${verticalStart}`;
1624
- pathData += ` L ${targetX} ${verticalEnd}`;
1625
- }
1626
- } else {
1627
- // Straight vertical line
1628
- pathData += ` L ${targetX} ${verticalEnd}`;
1629
- }
1630
-
1631
- pathData += ` L ${targetX} ${targetY}`;
1550
+ if (
1551
+ this.sourceId &&
1552
+ this.dragFromNodeId &&
1553
+ this.pendingCanvasConnection &&
1554
+ this.connectionSourceX != null &&
1555
+ this.connectionSourceY != null
1556
+ ) {
1557
+ const sourceX = this.connectionSourceX;
1558
+ const sourceY = this.connectionSourceY;
1559
+ const targetX = position.left + 100;
1560
+ // When dragging up, connect to the placeholder bottom; otherwise to the top
1561
+ const targetY = dragUp ? position.top + 64 : position.top;
1562
+
1563
+ const routeFace: 'top' | 'left' | 'right' = dragUp
1564
+ ? targetX < sourceX
1565
+ ? 'left'
1566
+ : 'right'
1567
+ : 'top';
1568
+
1569
+ const pathData = calculateFlowchartPath(
1570
+ sourceX,
1571
+ sourceY,
1572
+ targetX,
1573
+ targetY,
1574
+ 20,
1575
+ dragUp ? 0 : 10,
1576
+ 5,
1577
+ routeFace
1578
+ );
1632
1579
 
1633
- svgPath = html`
1634
- <svg
1635
- style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999;"
1636
- >
1637
- <path
1638
- d="${pathData}"
1639
- fill="none"
1640
- stroke="var(--color-connectors, #ccc)"
1641
- stroke-width="3"
1642
- class="plumb-connector"
1643
- />
1644
- <polygon
1645
- points="${targetX},${targetY} ${targetX - 6.5},${targetY -
1646
- 13} ${targetX + 6.5},${targetY - 13}"
1647
- fill="var(--color-connectors, #ccc)"
1648
- class="plumb-arrow"
1649
- />
1650
- </svg>
1651
- `;
1580
+ const aw = ARROW_HALF_WIDTH;
1581
+ const al = ARROW_LENGTH;
1582
+ let arrowPoints: string;
1583
+ if (dragUp) {
1584
+ // Arrow tip pointing up, base at placeholder bottom
1585
+ arrowPoints = `${targetX},${targetY - al} ${targetX - aw},${targetY} ${
1586
+ targetX + aw
1587
+ },${targetY}`;
1588
+ } else {
1589
+ // Arrow pointing down into top of placeholder
1590
+ arrowPoints = `${targetX},${targetY} ${targetX - aw},${targetY - al} ${
1591
+ targetX + aw
1592
+ },${targetY - al}`;
1652
1593
  }
1594
+
1595
+ svgPath = html`
1596
+ <svg
1597
+ style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999;"
1598
+ >
1599
+ <path
1600
+ d="${pathData}"
1601
+ fill="none"
1602
+ stroke="var(--color-connectors, #ccc)"
1603
+ stroke-width="3"
1604
+ />
1605
+ <polygon
1606
+ points="${arrowPoints}"
1607
+ fill="var(--color-connectors, #ccc)"
1608
+ />
1609
+ </svg>
1610
+ `;
1653
1611
  }
1654
1612
 
1655
1613
  return html`${svgPath}
@@ -1673,22 +1631,13 @@ export class Editor extends RapidElement {
1673
1631
 
1674
1632
  /**
1675
1633
  * Checks for node collisions and reflows nodes as needed.
1676
- * Nodes are only moved downward to resolve collisions.
1677
- *
1678
- * @param movedNodeUuids - UUIDs of nodes that were just moved/dropped
1679
- * @param droppedNodeUuid - UUID of the specific node that was dropped (if applicable)
1680
- * @param dropTargetBounds - Bounds of the node that was dropped onto (if applicable)
1634
+ * Sacred nodes (just moved/dropped) keep their positions while
1635
+ * other nodes are moved in the least-disruptive direction.
1681
1636
  */
1682
- private checkCollisionsAndReflow(
1683
- movedNodeUuids: string[],
1684
- droppedNodeUuid: string | null = null,
1685
- dropTargetBounds: NodeBounds | null = null
1686
- ): void {
1637
+ private checkCollisionsAndReflow(sacredNodeUuids: string[]): void {
1687
1638
  if (!this.definition) return;
1688
1639
 
1689
- // Get all node bounds (only for actual nodes, not stickies)
1690
1640
  const allBounds: NodeBounds[] = [];
1691
-
1692
1641
  for (const node of this.definition.nodes) {
1693
1642
  const nodeUI = this.definition._ui?.nodes[node.uuid];
1694
1643
  if (!nodeUI?.position) continue;
@@ -1699,45 +1648,17 @@ export class Editor extends RapidElement {
1699
1648
  }
1700
1649
  }
1701
1650
 
1702
- // Check if we need to determine midpoint priority for a dropped node
1703
- let targetHasPriority = false;
1704
- if (droppedNodeUuid && dropTargetBounds) {
1705
- const droppedBounds = allBounds.find((b) => b.uuid === droppedNodeUuid);
1706
- if (droppedBounds) {
1707
- // Check if the bottom of the dropped node is below the midpoint of the target
1708
- // If bottom is above midpoint, dropped node gets preference (targetHasPriority = false)
1709
- // If bottom is below midpoint, target gets preference (targetHasPriority = true)
1710
- const droppedBottom = droppedBounds.bottom;
1711
- const targetMidpoint =
1712
- dropTargetBounds.top + dropTargetBounds.height / 2;
1713
- targetHasPriority = droppedBottom > targetMidpoint;
1714
- }
1715
- }
1716
-
1717
- // Calculate reflow positions for each moved node
1718
- const allReflowPositions: { [uuid: string]: FlowPosition } = {};
1719
-
1720
- for (const movedUuid of movedNodeUuids) {
1721
- const movedBounds = allBounds.find((b) => b.uuid === movedUuid);
1722
- if (!movedBounds) continue;
1723
-
1724
- // Calculate reflow for this moved node
1725
- const reflowPositions = calculateReflowPositions(
1726
- movedUuid,
1727
- movedBounds,
1728
- allBounds,
1729
- droppedNodeUuid === movedUuid ? targetHasPriority : false
1730
- );
1651
+ const reflowPositions = calculateReflowPositions(
1652
+ sacredNodeUuids,
1653
+ allBounds
1654
+ );
1731
1655
 
1732
- // Merge into all reflow positions
1656
+ if (reflowPositions.size > 0) {
1657
+ const positions: { [uuid: string]: FlowPosition } = {};
1733
1658
  for (const [uuid, position] of reflowPositions.entries()) {
1734
- allReflowPositions[uuid] = position;
1659
+ positions[uuid] = position;
1735
1660
  }
1736
- }
1737
-
1738
- // If there are positions to update, apply them
1739
- if (Object.keys(allReflowPositions).length > 0) {
1740
- getStore().getState().updateCanvasPositions(allReflowPositions);
1661
+ getStore().getState().updateCanvasPositions(positions);
1741
1662
  }
1742
1663
  }
1743
1664
 
@@ -1780,23 +1701,41 @@ export class Editor extends RapidElement {
1780
1701
  this.isValidTarget = true;
1781
1702
 
1782
1703
  // Show connection placeholder when over empty canvas
1783
- // Calculate position: horizontally centered at mouse, vertically just below mouse
1784
1704
  const canvas = this.querySelector('#canvas');
1785
1705
  if (canvas) {
1786
1706
  const canvasRect = canvas.getBoundingClientRect();
1787
1707
  const relativeX = event.clientX - canvasRect.left;
1788
1708
  const relativeY = event.clientY - canvasRect.top;
1789
1709
 
1790
- // offset the placeholder so it's centered horizontally and just below the mouse
1791
- const placeholderWidth = 200; // approximate node width
1792
- const placeholderOffset = 20; // distance below mouse cursor
1710
+ const placeholderWidth = 200;
1711
+ const placeholderHeight = 64;
1712
+ const arrowLength = ARROW_LENGTH;
1713
+ const cursorGap = CURSOR_GAP;
1714
+
1715
+ // Determine if cursor is above the source exit using stored sourceY
1716
+ const dragUp =
1717
+ this.connectionSourceY != null
1718
+ ? relativeY < this.connectionSourceY
1719
+ : false;
1720
+
1721
+ let top: number;
1722
+ if (dragUp) {
1723
+ // Arrow points up: tip at cy + cursorGap.
1724
+ // Placeholder bottom should sit just above the arrow tip.
1725
+ top = relativeY + cursorGap - placeholderHeight;
1726
+ } else {
1727
+ // Arrow points down: tip at cy - cursorGap + arrowLength.
1728
+ // Placeholder top sits just below the arrow tip.
1729
+ top = relativeY - cursorGap + arrowLength;
1730
+ }
1793
1731
 
1794
1732
  this.connectionPlaceholder = {
1795
1733
  position: {
1796
1734
  left: relativeX - placeholderWidth / 2,
1797
- top: relativeY + placeholderOffset
1735
+ top
1798
1736
  },
1799
- visible: true
1737
+ visible: true,
1738
+ dragUp
1800
1739
  };
1801
1740
  }
1802
1741
  }
@@ -1924,49 +1863,7 @@ export class Editor extends RapidElement {
1924
1863
  if (nodeUuids.length > 0) {
1925
1864
  // Allow DOM to update before checking collisions
1926
1865
  setTimeout(() => {
1927
- // If only one node was moved, detect which node it might have been dropped onto
1928
- let droppedNodeUuid: string | null = null;
1929
- let dropTargetBounds: NodeBounds | null = null;
1930
-
1931
- if (nodeUuids.length === 1) {
1932
- droppedNodeUuid = nodeUuids[0];
1933
- const droppedNodeUI = this.definition._ui?.nodes[droppedNodeUuid];
1934
-
1935
- if (droppedNodeUI?.position) {
1936
- const droppedBounds = getNodeBounds(
1937
- droppedNodeUuid,
1938
- droppedNodeUI.position
1939
- );
1940
-
1941
- if (droppedBounds) {
1942
- // Find which node (if any) the dropped node overlaps with
1943
- for (const node of this.definition.nodes) {
1944
- if (node.uuid === droppedNodeUuid) continue;
1945
-
1946
- const nodeUI = this.definition._ui?.nodes[node.uuid];
1947
- if (!nodeUI?.position) continue;
1948
-
1949
- const targetBounds = getNodeBounds(
1950
- node.uuid,
1951
- nodeUI.position
1952
- );
1953
- if (
1954
- targetBounds &&
1955
- nodesOverlap(droppedBounds, targetBounds)
1956
- ) {
1957
- dropTargetBounds = targetBounds;
1958
- break; // Use the first overlapping node
1959
- }
1960
- }
1961
- }
1962
- }
1963
- }
1964
-
1965
- this.checkCollisionsAndReflow(
1966
- nodeUuids,
1967
- droppedNodeUuid,
1968
- dropTargetBounds
1969
- );
1866
+ this.checkCollisionsAndReflow(nodeUuids);
1970
1867
  }, 0);
1971
1868
  } else {
1972
1869
  // No nodes moved, just repaint connections
@@ -2091,6 +1988,8 @@ export class Editor extends RapidElement {
2091
1988
  this.pendingCanvasConnection = null;
2092
1989
  this.connectionPlaceholder = null;
2093
1990
  this.sourceId = null;
1991
+ this.connectionSourceX = null;
1992
+ this.connectionSourceY = null;
2094
1993
  this.dragFromNodeId = null;
2095
1994
  } else {
2096
1995
  // Show node type selector
@@ -2126,6 +2025,8 @@ export class Editor extends RapidElement {
2126
2025
  this.pendingCanvasConnection = null;
2127
2026
  this.connectionPlaceholder = null;
2128
2027
  this.sourceId = null;
2028
+ this.connectionSourceX = null;
2029
+ this.connectionSourceY = null;
2129
2030
  this.dragFromNodeId = null;
2130
2031
  this.originalConnectionTargetId = null;
2131
2032
  }
@@ -2362,6 +2263,8 @@ export class Editor extends RapidElement {
2362
2263
  this.pendingCanvasConnection = null;
2363
2264
  this.connectionPlaceholder = null;
2364
2265
  this.sourceId = null;
2266
+ this.connectionSourceX = null;
2267
+ this.connectionSourceY = null;
2365
2268
  this.dragFromNodeId = null;
2366
2269
  }
2367
2270
 
@@ -2431,6 +2334,8 @@ export class Editor extends RapidElement {
2431
2334
  this.pendingCanvasConnection = null;
2432
2335
  this.connectionPlaceholder = null;
2433
2336
  this.sourceId = null;
2337
+ this.connectionSourceX = null;
2338
+ this.connectionSourceY = null;
2434
2339
  this.dragFromNodeId = null;
2435
2340
  }
2436
2341
 
@@ -3430,7 +3335,7 @@ export class Editor extends RapidElement {
3430
3335
  icon="revisions"
3431
3336
  label="Revisions"
3432
3337
  color="rgb(142, 94, 167)"
3433
- top="105"
3338
+ order="1"
3434
3339
  .hidden=${!this.revisionsWindowHidden && this.localizationWindowHidden}
3435
3340
  @temba-button-clicked=${this.handleRevisionsTabClick}
3436
3341
  ></temba-floating-tab>
@@ -3728,7 +3633,7 @@ export class Editor extends RapidElement {
3728
3633
  icon="language"
3729
3634
  label="Translate Flow"
3730
3635
  color="#6b7280"
3731
- top="180"
3636
+ order="2"
3732
3637
  .hidden=${!this.localizationWindowHidden}
3733
3638
  @temba-button-clicked=${this.handleLocalizationTabClick}
3734
3639
  ></temba-floating-tab>