@nyaruka/temba-components 0.138.6 → 0.140.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 (196) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +26 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/locales/es.js +5 -5
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +5 -5
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/locale-codes.js +2 -11
  10. package/dist/locales/locale-codes.js.map +1 -1
  11. package/dist/locales/pt.js +5 -5
  12. package/dist/locales/pt.js.map +1 -1
  13. package/dist/temba-components.js +1112 -882
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/display/Chat.js +10 -7
  16. package/out-tsc/src/display/Chat.js.map +1 -1
  17. package/out-tsc/src/display/Dropdown.js +3 -1
  18. package/out-tsc/src/display/Dropdown.js.map +1 -1
  19. package/out-tsc/src/display/FloatingTab.js +25 -32
  20. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  21. package/out-tsc/src/display/Thumbnail.js +163 -5
  22. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  23. package/out-tsc/src/flow/CanvasMenu.js +5 -3
  24. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  25. package/out-tsc/src/flow/CanvasNode.js +70 -29
  26. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  27. package/out-tsc/src/flow/Editor.js +290 -239
  28. package/out-tsc/src/flow/Editor.js.map +1 -1
  29. package/out-tsc/src/flow/NodeEditor.js +118 -10
  30. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  31. package/out-tsc/src/flow/Plumber.js +757 -403
  32. package/out-tsc/src/flow/Plumber.js.map +1 -1
  33. package/out-tsc/src/flow/StickyNote.js +13 -4
  34. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  35. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  36. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  37. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  38. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  39. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  40. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  41. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  42. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  43. package/out-tsc/src/flow/config.js +11 -3
  44. package/out-tsc/src/flow/config.js.map +1 -1
  45. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  46. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  47. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  48. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  49. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  50. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  51. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  52. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  53. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  54. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  55. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  56. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  57. package/out-tsc/src/flow/operators.js +21 -5
  58. package/out-tsc/src/flow/operators.js.map +1 -1
  59. package/out-tsc/src/flow/types.js.map +1 -1
  60. package/out-tsc/src/flow/utils.js +213 -65
  61. package/out-tsc/src/flow/utils.js.map +1 -1
  62. package/out-tsc/src/form/ArrayEditor.js +4 -2
  63. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  64. package/out-tsc/src/form/FieldRenderer.js +49 -0
  65. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  66. package/out-tsc/src/interfaces.js +2 -0
  67. package/out-tsc/src/interfaces.js.map +1 -1
  68. package/out-tsc/src/layout/Dialog.js +52 -7
  69. package/out-tsc/src/layout/Dialog.js.map +1 -1
  70. package/out-tsc/src/list/TicketList.js +4 -1
  71. package/out-tsc/src/list/TicketList.js.map +1 -1
  72. package/out-tsc/src/live/TembaChart.js.map +1 -1
  73. package/out-tsc/src/locales/es.js +5 -5
  74. package/out-tsc/src/locales/es.js.map +1 -1
  75. package/out-tsc/src/locales/fr.js +5 -5
  76. package/out-tsc/src/locales/fr.js.map +1 -1
  77. package/out-tsc/src/locales/locale-codes.js +2 -11
  78. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  79. package/out-tsc/src/locales/pt.js +5 -5
  80. package/out-tsc/src/locales/pt.js.map +1 -1
  81. package/out-tsc/src/simulator/Simulator.js +10 -3
  82. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  83. package/out-tsc/src/store/AppState.js +89 -3
  84. package/out-tsc/src/store/AppState.js.map +1 -1
  85. package/out-tsc/test/actions/play_audio.test.js +118 -0
  86. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  87. package/out-tsc/test/actions/say_msg.test.js +158 -0
  88. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  89. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  90. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  91. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  92. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  93. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  94. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  95. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  96. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  97. package/out-tsc/test/temba-floating-tab.test.js +4 -6
  98. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  99. package/out-tsc/test/temba-flow-collision.test.js +473 -220
  100. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  101. package/out-tsc/test/temba-flow-editor.test.js +0 -2
  102. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  103. package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
  104. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  105. package/out-tsc/test/temba-flow-plumber.test.js +102 -93
  106. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  107. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  108. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  109. package/package.json +1 -1
  110. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  111. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  112. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  113. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  114. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  115. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  116. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  117. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  118. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  119. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  120. package/screenshots/truth/editor/router.png +0 -0
  121. package/screenshots/truth/editor/wait.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  124. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  125. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  126. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  127. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  128. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  129. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  141. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  142. package/src/display/Chat.ts +13 -7
  143. package/src/display/Dropdown.ts +3 -1
  144. package/src/display/FloatingTab.ts +24 -33
  145. package/src/display/Thumbnail.ts +162 -2
  146. package/src/flow/CanvasMenu.ts +8 -3
  147. package/src/flow/CanvasNode.ts +75 -30
  148. package/src/flow/Editor.ts +336 -288
  149. package/src/flow/NodeEditor.ts +137 -9
  150. package/src/flow/Plumber.ts +1011 -457
  151. package/src/flow/StickyNote.ts +14 -4
  152. package/src/flow/actions/audio-player.ts +127 -0
  153. package/src/flow/actions/enter_flow.ts +44 -0
  154. package/src/flow/actions/play_audio.ts +64 -5
  155. package/src/flow/actions/say_msg.ts +94 -4
  156. package/src/flow/config.ts +11 -3
  157. package/src/flow/nodes/shared-rules.ts +1 -1
  158. package/src/flow/nodes/terminal.ts +9 -0
  159. package/src/flow/nodes/wait_for_audio.ts +88 -0
  160. package/src/flow/nodes/wait_for_dial.ts +176 -0
  161. package/src/flow/nodes/wait_for_digits.ts +86 -2
  162. package/src/flow/nodes/wait_for_menu.ts +209 -3
  163. package/src/flow/operators.ts +23 -5
  164. package/src/flow/types.ts +23 -1
  165. package/src/flow/utils.ts +238 -81
  166. package/src/form/ArrayEditor.ts +4 -2
  167. package/src/form/FieldRenderer.ts +64 -1
  168. package/src/interfaces.ts +3 -1
  169. package/src/layout/Dialog.ts +53 -7
  170. package/src/list/TicketList.ts +4 -1
  171. package/src/live/TembaChart.ts +1 -1
  172. package/src/locales/es.ts +13 -18
  173. package/src/locales/fr.ts +13 -18
  174. package/src/locales/locale-codes.ts +2 -11
  175. package/src/locales/pt.ts +13 -18
  176. package/src/simulator/Simulator.ts +13 -3
  177. package/src/store/AppState.ts +105 -1
  178. package/src/store/flow-definition.d.ts +2 -0
  179. package/test/actions/play_audio.test.ts +155 -0
  180. package/test/actions/say_msg.test.ts +196 -0
  181. package/test/nodes/wait_for_audio.test.ts +182 -0
  182. package/test/nodes/wait_for_dial.test.ts +382 -0
  183. package/test/nodes/wait_for_digits.test.ts +233 -109
  184. package/test/nodes/wait_for_menu.test.ts +383 -0
  185. package/test/temba-floating-tab.test.ts +4 -6
  186. package/test/temba-flow-collision.test.ts +495 -293
  187. package/test/temba-flow-editor.test.ts +0 -2
  188. package/test/temba-flow-plumber-connections.test.ts +97 -97
  189. package/test/temba-flow-plumber.test.ts +116 -103
  190. package/test/temba-node-type-selector.test.ts +6 -6
  191. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -1,145 +1,203 @@
1
- import {
2
- DotEndpoint,
3
- FlowchartConnector,
4
- newInstance,
5
- ready,
6
- RectangleEndpoint,
7
- EVENT_CONNECTION_DRAG,
8
- EVENT_CONNECTION_ABORT,
9
- INTERCEPT_BEFORE_DROP,
10
- EVENT_CONNECTION,
11
- EVENT_REVERT,
12
- INTERCEPT_BEFORE_DETACH,
13
- EVENT_CONNECTION_DETACHED
14
- } from '@jsplumb/browser-ui';
15
- import { getStore } from '../store/Store';
16
-
17
- const CONNECTOR_DEFAULTS = {
18
- type: FlowchartConnector.type,
19
- options: {
20
- stub: [20, 10],
21
- midpoint: 0.5,
22
- alwaysRespectStubs: true,
23
- cornerRadius: 5,
24
- cssClass: 'plumb-connector'
25
- }
26
- };
27
-
28
- const OVERLAYS_DEFAULTS = [
29
- {
30
- type: 'PlainArrow',
31
- options: {
32
- width: 13,
33
- length: 13,
34
- location: 0.999,
35
- cssClass: 'plumb-arrow'
36
- }
37
- }
38
- ];
39
-
40
- export const SOURCE_DEFAULTS = {
41
- endpoint: {
42
- type: DotEndpoint.type,
43
- options: {
44
- radius: 12,
45
- cssClass: 'plumb-source',
46
- hoverClass: 'plumb-source-hover'
47
- }
48
- },
49
- anchors: ['Bottom', 'Continuous'],
50
- maxConnections: 1,
51
- source: true,
52
- dragAllowedWhenFull: false
53
- };
54
-
55
- export const TARGET_DEFAULTS = {
56
- endpoint: {
57
- type: RectangleEndpoint.type,
58
- options: {
59
- width: 23,
60
- height: 23,
61
- cssClass: 'plumb-target',
62
- hoverClass: 'plumb-target-hover'
63
- }
64
- },
65
- anchor: {
66
- type: 'Continuous',
67
- options: {
68
- faces: ['top', 'left', 'right'],
69
- cssClass: 'continuos plumb-target-anchor'
70
- }
71
- },
72
- deleteOnEmpty: true,
73
- maxConnections: 1,
74
- target: true
75
- };
1
+ export type TargetFace = 'top' | 'left' | 'right';
2
+
3
+ // Shared arrow/drag constants used by both Plumber and Editor
4
+ export const ARROW_LENGTH = 13;
5
+ export const ARROW_HALF_WIDTH = 6.5;
6
+ export const CURSOR_GAP = 1;
7
+ export const EXIT_STUB = 30;
8
+
9
+ interface ConnectionEndpoints {
10
+ sourceX: number;
11
+ sourceY: number;
12
+ targetX: number;
13
+ targetY: number;
14
+ targetFace: TargetFace;
15
+ }
16
+
17
+ interface ConnectionInfo {
18
+ scope: string; // nodeId
19
+ fromId: string; // exitId
20
+ toId: string; // target nodeId
21
+ svgEl: SVGSVGElement;
22
+ pathEl: SVGPathElement;
23
+ arrowEl: SVGPolygonElement;
24
+ }
25
+
26
+ interface DragState {
27
+ sourceId: string;
28
+ scope: string;
29
+ originalTargetId: string | null;
30
+ svgEl: SVGSVGElement;
31
+ pathEl: SVGPathElement;
32
+ arrowEl: SVGPolygonElement;
33
+ onMove: (e: MouseEvent) => void;
34
+ onUp: (e: MouseEvent) => void;
35
+ }
36
+
37
+ /**
38
+ * Calculate a flowchart-style SVG path between two points.
39
+ * Routes with right-angle segments, stubs at each end, and rounded corners.
40
+ * Supports entering the target from top, left, or right faces.
41
+ */
42
+ export function calculateFlowchartPath(
43
+ sourceX: number,
44
+ sourceY: number,
45
+ targetX: number,
46
+ targetY: number,
47
+ stubStart = 20,
48
+ stubEnd = 10,
49
+ cornerRadius = 5,
50
+ targetFace: TargetFace = 'top'
51
+ ): string {
52
+ const r = cornerRadius;
53
+
54
+ if (targetFace === 'top') {
55
+ // Target is below (or we treat it as such): exit down, horizontal jog, enter from top
56
+ const exitY = sourceY + stubStart;
57
+ const entryY = targetY - stubEnd;
58
+
59
+ let d = `M ${sourceX} ${sourceY}`;
60
+
61
+ if (sourceX === targetX) {
62
+ // Straight vertical — no turns needed
63
+ d += ` L ${targetX} ${entryY}`;
64
+ } else {
65
+ // L-shape: exit curves horizontal, then straight down to target.
66
+ // jogY is the horizontal level — must be above entryY so the
67
+ // final approach into the node is always downward (no backtracking).
68
+ const dirX = targetX > sourceX ? 1 : -1;
69
+ const jogY = Math.max(sourceY + r, Math.min(exitY, entryY - r));
70
+
71
+ // Corner 1: vertical→horizontal at jogY
72
+ const r1 = Math.min(r, jogY - sourceY);
73
+ if (r1 >= 1) {
74
+ d += ` L ${sourceX} ${jogY - r1}`;
75
+ d += ` Q ${sourceX} ${jogY}, ${sourceX + dirX * r1} ${jogY}`;
76
+ } else {
77
+ d += ` L ${sourceX} ${jogY}`;
78
+ }
79
+
80
+ // Corner 2: horizontal→vertical at targetX — leave minSeg of
81
+ // straight line after the curve before reaching entryY
82
+ const minSeg = 3;
83
+ const r2 = Math.min(r, Math.max(0, entryY - jogY - minSeg));
84
+ if (r2 >= 1) {
85
+ d += ` L ${targetX - dirX * r2} ${jogY}`;
86
+ d += ` Q ${targetX} ${jogY}, ${targetX} ${jogY + r2}`;
87
+ } else {
88
+ d += ` L ${targetX} ${jogY}`;
89
+ }
90
+ d += ` L ${targetX} ${entryY}`;
91
+ }
92
+
93
+ d += ` L ${targetX} ${targetY}`;
94
+ return d;
95
+ }
96
+
97
+ if (targetFace === 'left' || targetFace === 'right') {
98
+ // Route: exit down from source, horizontal jog, vertical to target Y, stub into side
99
+ // When target is above source, skip the exit stub so the path turns horizontal
100
+ // as quickly as possible (only the corner radius creates downward travel)
101
+ const goingUp = targetY < sourceY;
102
+ const exitY = sourceY + (goingUp ? 0 : stubStart);
103
+ const sideDir = targetFace === 'left' ? -1 : 1;
104
+ // Entry point is OUTSIDE the node boundary (stub behind arrowhead)
105
+ const entryX = targetX + sideDir * stubEnd;
106
+
107
+ const dirX = entryX > sourceX ? 1 : -1;
108
+
109
+ // Minimum straight segment after each curve
110
+ const minSeg = 3;
111
+
112
+ // When the horizontal approach would double-back over the stub
113
+ // (dirX matches sideDir), keep midY at the natural exit level so
114
+ // the path jogs horizontally ABOVE the target and descends into
115
+ // the stub — never dipping past the target and curving back up.
116
+ // For non-backtrack, midY goes to targetY for a direct entry.
117
+ const midY =
118
+ dirX === sideDir ? exitY + r * 2 : Math.max(exitY + r * 2, targetY);
119
+
120
+ let d = `M ${sourceX} ${sourceY} L ${sourceX} ${exitY}`;
121
+
122
+ // Corner 1: vertical→horizontal at (sourceX, midY)
123
+ if (midY - exitY > r) {
124
+ d += ` L ${sourceX} ${midY - r}`;
125
+ d += ` Q ${sourceX} ${midY}, ${sourceX + dirX * r} ${midY}`;
126
+ }
127
+
128
+ const vertGap = Math.abs(midY - targetY);
129
+
130
+ if (vertGap < 1) {
131
+ // midY ≈ targetY — horizontal to entryX, then stub into face
132
+ d += ` L ${entryX} ${targetY}`;
133
+ d += ` L ${targetX} ${targetY}`;
134
+ } else {
135
+ // Corners 2 and 3 — turnR is limited so that at least minSeg of
136
+ // straight line remains between the two corners and after corner 3
137
+ const turnDir = targetY < midY ? -1 : 1;
138
+ const turnR = Math.min(
139
+ r,
140
+ Math.max(0, Math.floor((vertGap - minSeg) / 2)),
141
+ Math.max(0, stubEnd - minSeg)
142
+ );
143
+
144
+ if (turnR >= 1) {
145
+ // Corner 2: horizontal→vertical at (entryX, midY)
146
+ d += ` L ${entryX - dirX * turnR} ${midY}`;
147
+ d += ` Q ${entryX} ${midY}, ${entryX} ${midY + turnDir * turnR}`;
148
+ // Vertical toward targetY
149
+ d += ` L ${entryX} ${targetY - turnDir * turnR}`;
150
+ // Corner 3: vertical→horizontal into side face
151
+ d += ` Q ${entryX} ${targetY}, ${entryX - sideDir * turnR} ${targetY}`;
152
+ } else {
153
+ d += ` L ${entryX} ${midY}`;
154
+ d += ` L ${entryX} ${targetY}`;
155
+ }
156
+ // Horizontal stub into target face
157
+ d += ` L ${targetX} ${targetY}`;
158
+ }
159
+
160
+ return d;
161
+ }
162
+
163
+ return `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`;
164
+ }
76
165
 
77
166
  export class Plumber {
78
- private jsPlumb = null;
79
- private pendingConnections = [];
80
- private connectionListeners = new Map();
81
- public connectionDragging = false;
82
- private connectionWait = null;
167
+ private connections: Map<string, ConnectionInfo> = new Map();
168
+ private sources: Map<string, () => void> = new Map(); // exitId → cleanup fn
169
+ private canvas: HTMLElement;
170
+ private pendingConnections: {
171
+ scope: string;
172
+ fromId: string;
173
+ toId: string;
174
+ }[] = [];
175
+ private connectionWait: number | null = null;
176
+ private connectionListeners: Map<string, ((info: any) => void)[]> = new Map();
177
+ private dragState: DragState | null = null;
178
+ private editor: any;
179
+ private retryCount = 0;
180
+ private maxRetries = 3;
181
+
182
+ // Activity overlay state
83
183
  private activityData: { segments: { [key: string]: number } } | null = null;
184
+ private overlays: Map<string, HTMLElement> = new Map();
84
185
  private hoveredActivityKey: string | null = null;
85
186
  private recentContactsPopup: HTMLElement | null = null;
86
187
  private recentContactsCache: { [key: string]: any[] } = {};
87
188
  private pendingFetches: { [key: string]: AbortController } = {};
88
189
  private hideContactsTimeout: number | null = null;
89
190
  private showContactsTimeout: number | null = null;
90
- private editor: any;
91
-
92
- initializeJSPlumb(canvas: HTMLElement) {
93
- this.jsPlumb = newInstance({
94
- container: canvas,
95
- connectionsDetachable: true,
96
- endpointStyle: {
97
- fill: 'green'
98
- },
99
- connector: CONNECTOR_DEFAULTS,
100
- connectionOverlays: OVERLAYS_DEFAULTS
101
- });
102
-
103
- // Bind to connection events
104
- this.jsPlumb.bind(EVENT_CONNECTION, (info) => {
105
- this.connectionDragging = false;
106
- this.notifyListeners(EVENT_CONNECTION, info);
107
- });
108
-
109
- // Bind to connection drag events
110
- this.jsPlumb.bind(EVENT_CONNECTION_DRAG, (info) => {
111
- this.connectionDragging = true;
112
- this.notifyListeners(EVENT_CONNECTION_DRAG, info);
113
- });
114
191
 
115
- this.jsPlumb.bind(EVENT_CONNECTION_ABORT, (info) => {
116
- this.connectionDragging = false;
117
- this.notifyListeners(EVENT_CONNECTION_ABORT, info);
118
- });
119
-
120
- this.jsPlumb.bind(EVENT_CONNECTION_DETACHED, (info) => {
121
- this.connectionDragging = false;
122
- this.notifyListeners(EVENT_CONNECTION_DETACHED, info);
123
- });
124
-
125
- this.jsPlumb.bind(EVENT_REVERT, (info) => {
126
- this.notifyListeners(EVENT_REVERT, info);
127
- });
128
-
129
- this.jsPlumb.bind(INTERCEPT_BEFORE_DROP, () => {
130
- // we always deny automatic connections
131
- return false;
132
- });
133
- this.jsPlumb.bind(INTERCEPT_BEFORE_DETACH, () => {});
134
- }
192
+ public connectionDragging = false;
135
193
 
136
194
  constructor(canvas: HTMLElement, editor: any) {
195
+ this.canvas = canvas;
137
196
  this.editor = editor;
138
- ready(() => {
139
- this.initializeJSPlumb(canvas);
140
- });
141
197
  }
142
198
 
199
+ // --- Event system ---
200
+
143
201
  private notifyListeners(eventName: string, info: any) {
144
202
  const listeners = this.connectionListeners.get(eventName) || [];
145
203
  listeners.forEach((listener) => listener(info));
@@ -161,226 +219,704 @@ export class Plumber {
161
219
  }
162
220
  }
163
221
 
164
- public makeTarget(uuid: string) {
165
- const element = document.getElementById(uuid);
222
+ // --- Source/Target registration ---
223
+
224
+ public makeSource(exitId: string) {
225
+ const element = document.getElementById(exitId);
166
226
  if (!element) return;
167
- return this.jsPlumb.addEndpoint(element, TARGET_DEFAULTS);
227
+
228
+ // Clean up any existing listener for this exit
229
+ if (this.sources.has(exitId)) {
230
+ this.sources.get(exitId)();
231
+ }
232
+
233
+ let pendingDrag: {
234
+ startX: number;
235
+ startY: number;
236
+ onMove: (e: MouseEvent) => void;
237
+ onUp: (e: MouseEvent) => void;
238
+ } | null = null;
239
+
240
+ const DRAG_THRESHOLD = 5;
241
+
242
+ const onMouseDown = (e: MouseEvent) => {
243
+ if (e.button !== 0) return;
244
+
245
+ // Don't start drag from exit if it already has a connection —
246
+ // existing connections are picked up from the arrowhead instead
247
+ if (this.connections.has(exitId)) return;
248
+
249
+ const startX = e.clientX;
250
+ const startY = e.clientY;
251
+
252
+ const nodeEl = element.closest('temba-flow-node');
253
+ const scope = nodeEl?.getAttribute('uuid') || '';
254
+ const originalTargetId: string | null = null;
255
+
256
+ const onMove = (me: MouseEvent) => {
257
+ const dx = me.clientX - startX;
258
+ const dy = me.clientY - startY;
259
+ if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
260
+ // Exceeded threshold — start actual drag
261
+ document.removeEventListener('mousemove', onMove);
262
+ document.removeEventListener('mouseup', onUp);
263
+ pendingDrag = null;
264
+ this.startDrag(exitId, scope, originalTargetId, me);
265
+ }
266
+ };
267
+
268
+ const onUp = () => {
269
+ // Mouse released without dragging — let click handler fire
270
+ document.removeEventListener('mousemove', onMove);
271
+ document.removeEventListener('mouseup', onUp);
272
+ pendingDrag = null;
273
+ };
274
+
275
+ document.addEventListener('mousemove', onMove);
276
+ document.addEventListener('mouseup', onUp);
277
+ pendingDrag = { startX, startY, onMove, onUp };
278
+ };
279
+
280
+ element.addEventListener('mousedown', onMouseDown);
281
+ this.sources.set(exitId, () => {
282
+ element.removeEventListener('mousedown', onMouseDown);
283
+ if (pendingDrag) {
284
+ document.removeEventListener('mousemove', pendingDrag.onMove);
285
+ document.removeEventListener('mouseup', pendingDrag.onUp);
286
+ pendingDrag = null;
287
+ }
288
+ });
168
289
  }
169
290
 
170
- public makeSource(uuid: string) {
171
- const element = document.getElementById(uuid);
172
- if (!element) return;
173
- return this.jsPlumb.addEndpoint(element, SOURCE_DEFAULTS);
291
+ public makeTarget(_nodeId: string) {
292
+ // No-op: target detection happens via DOM hover during drag
293
+ }
294
+
295
+ // --- Connection creation ---
296
+
297
+ public connectIds(scope: string, fromId: string, toId: string) {
298
+ this.pendingConnections.push({ scope, fromId, toId });
299
+ this.processPendingConnections();
174
300
  }
175
301
 
176
- // we'll process our pending connections, but we want to debounce this
177
302
  public processPendingConnections() {
178
- // if we have a pending connection wait, clear it
179
303
  if (this.connectionWait) {
180
- clearTimeout(this.connectionWait);
304
+ cancelAnimationFrame(this.connectionWait);
181
305
  this.connectionWait = null;
182
306
  }
183
307
 
184
- // debounce the connection processing
185
- this.connectionWait = setTimeout(() => {
186
- this.jsPlumb.batch(() => {
187
- this.pendingConnections.forEach((connection) => {
188
- const { scope, fromId, toId } = connection;
308
+ this.connectionWait = requestAnimationFrame(() => {
309
+ const failed: { scope: string; fromId: string; toId: string }[] = [];
310
+ const createdTargets = new Set<string>();
311
+
312
+ this.pendingConnections.forEach((conn) => {
313
+ const { scope, fromId, toId } = conn;
314
+ // Remove existing connection from this exit if any
315
+ this.removeConnectionSVG(fromId);
316
+ if (!this.createConnectionSVG(fromId, scope, toId)) {
317
+ failed.push(conn);
318
+ } else {
319
+ createdTargets.add(toId);
320
+ }
321
+ });
322
+ this.pendingConnections = [];
323
+
324
+ // Repaint all connections that share a target with newly created ones
325
+ // so anchor distribution is correct after the full batch is processed
326
+ if (createdTargets.size > 0) {
327
+ this.connections.forEach((conn, exitId) => {
328
+ if (createdTargets.has(conn.toId)) {
329
+ this.updateConnectionSVG(exitId);
330
+ }
331
+ });
332
+ }
333
+
334
+ // Retry failed connections (elements may not be laid out yet)
335
+ if (failed.length > 0 && this.retryCount < this.maxRetries) {
336
+ this.retryCount++;
337
+ this.pendingConnections = failed;
338
+ this.processPendingConnections();
339
+ } else {
340
+ this.retryCount = 0;
341
+ }
342
+ });
343
+ }
344
+
345
+ // --- Anchor point distribution ---
346
+
347
+ private determineTargetFace(
348
+ sourceX: number,
349
+ sourceY: number,
350
+ targetRect: DOMRect,
351
+ canvasRect: DOMRect
352
+ ): TargetFace {
353
+ const targetCenterX =
354
+ targetRect.left + targetRect.width / 2 - canvasRect.left;
355
+ const targetTop = targetRect.top - canvasRect.top;
356
+ const verticalGap = targetTop - sourceY;
357
+
358
+ // Top face requires enough vertical room for the exit stub, entry stub,
359
+ // arrow, and curved corners. Below this threshold the path components
360
+ // overlap and the connection backtracks, so use a side face instead.
361
+ if (verticalGap > 30) {
362
+ return 'top';
363
+ }
364
+
365
+ // Source is level with, below, or too close to target — connect to a side face
366
+ if (sourceX < targetCenterX) {
367
+ return 'left';
368
+ }
369
+ return 'right';
370
+ }
189
371
 
190
- // sources and targets must exist
191
- const source = document.getElementById(fromId);
192
- // const target = document.getElementById(toId);
372
+ private getConnectionEndpoints(
373
+ fromId: string,
374
+ toId: string
375
+ ): ConnectionEndpoints | null {
376
+ const fromEl = document.getElementById(fromId);
377
+ const toEl = document.getElementById(toId);
378
+ if (!fromEl || !toEl) return null;
193
379
 
194
- this.revalidate([fromId, toId]);
380
+ const canvasRect = this.canvas.getBoundingClientRect();
381
+ const fromRect = fromEl.getBoundingClientRect();
382
+ const toRect = toEl.getBoundingClientRect();
195
383
 
196
- // we need to find the source endpoint
197
- const sourceEndpoint = this.jsPlumb
198
- .getEndpoints(source)
199
- ?.find((endpoint) =>
200
- endpoint.elementId === fromId ? true : false
201
- );
384
+ if (fromRect.width === 0 || toRect.width === 0) return null;
202
385
 
203
- // update endpoint have connect css class
204
- if (sourceEndpoint) {
205
- sourceEndpoint.addClass('connected');
206
- }
386
+ const sourceX = fromRect.left + fromRect.width / 2 - canvasRect.left;
387
+ const sourceY = fromRect.bottom - canvasRect.top;
207
388
 
208
- // each connection needs its own target endpoint
209
- const targetEndpoint = this.makeTarget(toId);
389
+ const targetFace = this.determineTargetFace(
390
+ sourceX,
391
+ sourceY,
392
+ toRect,
393
+ canvasRect
394
+ );
210
395
 
211
- if (!source || !targetEndpoint) {
212
- console.warn(
213
- `Plumber: Cannot connect ${fromId} to ${toId}. Element(s) missing.`
214
- );
215
- return;
396
+ // Find all connections targeting the same node, grouped by face
397
+ // Track source position for spatial sorting
398
+ const faceConnections: Map<
399
+ TargetFace,
400
+ { fromId: string; sortPos: number }[]
401
+ > = new Map();
402
+ this.connections.forEach((conn) => {
403
+ if (conn.toId === toId) {
404
+ const connFromEl = document.getElementById(conn.fromId);
405
+ if (connFromEl) {
406
+ const connFromRect = connFromEl.getBoundingClientRect();
407
+ const connSourceX =
408
+ connFromRect.left + connFromRect.width / 2 - canvasRect.left;
409
+ const connSourceY = connFromRect.bottom - canvasRect.top;
410
+ const face = this.determineTargetFace(
411
+ connSourceX,
412
+ connSourceY,
413
+ toRect,
414
+ canvasRect
415
+ );
416
+ if (!faceConnections.has(face)) {
417
+ faceConnections.set(face, []);
216
418
  }
419
+ // Sort position: X for top face, Y for side faces
420
+ const sortPos = face === 'top' ? connSourceX : connSourceY;
421
+ faceConnections.get(face).push({ fromId: conn.fromId, sortPos });
422
+ }
423
+ }
424
+ });
217
425
 
218
- // delete connections
219
- this.jsPlumb.select({ source, targetEndpoint }).deleteAll();
220
- this.jsPlumb.connect({
221
- source: source,
222
- target: targetEndpoint,
223
- connector: {
224
- ...CONNECTOR_DEFAULTS,
225
- options: { ...CONNECTOR_DEFAULTS.options, gap: [0, 5] }
226
- },
227
- data: {
228
- nodeId: scope
229
- }
230
- });
231
- });
232
- this.pendingConnections = [];
426
+ // Add current connection to its face group if not already tracked
427
+ if (!faceConnections.has(targetFace)) {
428
+ faceConnections.set(targetFace, []);
429
+ }
430
+ const faceGroup = faceConnections.get(targetFace);
431
+ if (!faceGroup.find((e) => e.fromId === fromId)) {
432
+ const sortPos = targetFace === 'top' ? sourceX : sourceY;
433
+ faceGroup.push({ fromId, sortPos });
434
+ }
435
+
436
+ // Sort by spatial position so connections don't cross
437
+ faceGroup.sort((a, b) => a.sortPos - b.sortPos);
438
+ const index = faceGroup.findIndex((e) => e.fromId === fromId);
439
+ const count = faceGroup.length;
440
+
441
+ // Calculate anchor point on the chosen face
442
+ const targetLeft = toRect.left - canvasRect.left;
443
+ const targetTop = toRect.top - canvasRect.top;
444
+ const targetW = toRect.width;
445
+ const targetH = toRect.height;
446
+
447
+ let targetX: number;
448
+ let targetY: number;
449
+
450
+ if (targetFace === 'top') {
451
+ // Distribute across top face (middle 60% of width)
452
+ const margin = targetW * 0.2;
453
+ const span = targetW * 0.6;
454
+ targetX =
455
+ count === 1
456
+ ? targetLeft + targetW / 2
457
+ : targetLeft + margin + (span * (index + 0.5)) / count;
458
+ targetY = targetTop;
459
+ } else if (targetFace === 'left') {
460
+ targetX = targetLeft;
461
+ // Distribute along left face (middle 60% of height)
462
+ const margin = targetH * 0.2;
463
+ const span = targetH * 0.6;
464
+ targetY =
465
+ count === 1
466
+ ? targetTop + targetH / 2
467
+ : targetTop + margin + (span * (index + 0.5)) / count;
468
+ } else {
469
+ // right
470
+ targetX = targetLeft + targetW;
471
+ const margin = targetH * 0.2;
472
+ const span = targetH * 0.6;
473
+ targetY =
474
+ count === 1
475
+ ? targetTop + targetH / 2
476
+ : targetTop + margin + (span * (index + 0.5)) / count;
477
+ }
478
+
479
+ return { sourceX, sourceY, targetX, targetY, targetFace };
480
+ }
481
+
482
+ // --- SVG creation and management ---
483
+
484
+ private createSVGElement(): {
485
+ svgEl: SVGSVGElement;
486
+ pathEl: SVGPathElement;
487
+ arrowEl: SVGPolygonElement;
488
+ } {
489
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
490
+ svgEl.classList.add('plumb-connector');
491
+ svgEl.style.position = 'absolute';
492
+ svgEl.style.left = '0';
493
+ svgEl.style.top = '0';
494
+ svgEl.style.width = '100%';
495
+ svgEl.style.height = '100%';
496
+ svgEl.style.pointerEvents = 'none';
497
+ svgEl.style.overflow = 'visible';
498
+
499
+ const pathEl = document.createElementNS(
500
+ 'http://www.w3.org/2000/svg',
501
+ 'path'
502
+ );
503
+ pathEl.setAttribute('fill', 'none');
504
+ pathEl.setAttribute('stroke', 'var(--color-connectors)');
505
+ pathEl.setAttribute('stroke-width', '3');
506
+ pathEl.style.pointerEvents = 'stroke';
507
+
508
+ const arrowEl = document.createElementNS(
509
+ 'http://www.w3.org/2000/svg',
510
+ 'polygon'
511
+ );
512
+ arrowEl.setAttribute('fill', 'var(--color-connectors)');
513
+ arrowEl.classList.add('plumb-arrow');
514
+ arrowEl.style.pointerEvents = 'fill';
515
+ arrowEl.style.cursor = 'pointer';
516
+
517
+ svgEl.appendChild(pathEl);
518
+ svgEl.appendChild(arrowEl);
519
+
520
+ // Hover support
521
+ const addHover = () => svgEl.classList.add('hover');
522
+ const removeHover = () => svgEl.classList.remove('hover');
523
+ pathEl.addEventListener('mouseenter', addHover);
524
+ pathEl.addEventListener('mouseleave', removeHover);
525
+ arrowEl.addEventListener('mouseenter', addHover);
526
+ arrowEl.addEventListener('mouseleave', removeHover);
527
+
528
+ return { svgEl, pathEl, arrowEl };
529
+ }
530
+
531
+ private updateSVGPath(
532
+ pathEl: SVGPathElement,
533
+ arrowEl: SVGPolygonElement,
534
+ sourceX: number,
535
+ sourceY: number,
536
+ targetX: number,
537
+ targetY: number,
538
+ targetFace: TargetFace = 'top'
539
+ ) {
540
+ const aw = ARROW_HALF_WIDTH;
541
+ const al = ARROW_LENGTH;
542
+ const stubBehindArrow = 8;
543
+
544
+ // Path ends at arrow BASE (not tip) so the line never pokes through the front.
545
+ // The arrow polygon covers from base to the node edge (tip).
546
+ let pathTargetX = targetX;
547
+ let pathTargetY = targetY;
548
+ if (targetFace === 'top') {
549
+ pathTargetY = targetY - al;
550
+ } else if (targetFace === 'left') {
551
+ pathTargetX = targetX - al;
552
+ } else if (targetFace === 'right') {
553
+ pathTargetX = targetX + al;
554
+ }
555
+
556
+ const effectiveStub = stubBehindArrow;
557
+ const d = calculateFlowchartPath(
558
+ sourceX,
559
+ sourceY,
560
+ pathTargetX,
561
+ pathTargetY,
562
+ EXIT_STUB,
563
+ effectiveStub,
564
+ 5,
565
+ targetFace
566
+ );
567
+ pathEl.setAttribute('d', d);
568
+
569
+ // Arrow tip at node edge, base extends outward
570
+ if (targetFace === 'top') {
571
+ arrowEl.setAttribute(
572
+ 'points',
573
+ `${targetX},${targetY} ${targetX - aw},${targetY - al} ${
574
+ targetX + aw
575
+ },${targetY - al}`
576
+ );
577
+ } else if (targetFace === 'left') {
578
+ arrowEl.setAttribute(
579
+ 'points',
580
+ `${targetX},${targetY} ${targetX - al},${targetY - aw} ${
581
+ targetX - al
582
+ },${targetY + aw}`
583
+ );
584
+ } else {
585
+ arrowEl.setAttribute(
586
+ 'points',
587
+ `${targetX},${targetY} ${targetX + al},${targetY - aw} ${
588
+ targetX + al
589
+ },${targetY + aw}`
590
+ );
591
+ }
592
+ }
593
+
594
+ private createConnectionSVG(
595
+ exitId: string,
596
+ scope: string,
597
+ toId: string
598
+ ): boolean {
599
+ const endpoints = this.getConnectionEndpoints(exitId, toId);
600
+ if (!endpoints) return false;
601
+
602
+ const { svgEl, pathEl, arrowEl } = this.createSVGElement();
603
+ this.updateSVGPath(
604
+ pathEl,
605
+ arrowEl,
606
+ endpoints.sourceX,
607
+ endpoints.sourceY,
608
+ endpoints.targetX,
609
+ endpoints.targetY,
610
+ endpoints.targetFace
611
+ );
612
+ this.canvas.appendChild(svgEl);
613
+
614
+ this.connections.set(exitId, {
615
+ scope,
616
+ fromId: exitId,
617
+ toId,
618
+ svgEl,
619
+ pathEl,
620
+ arrowEl
621
+ });
622
+
623
+ // Make arrowhead draggable for picking up existing connections
624
+ const DRAG_THRESHOLD = 5;
625
+ const onArrowMouseDown = (e: MouseEvent) => {
626
+ if (e.button !== 0) return;
627
+ e.stopPropagation();
628
+
629
+ const startX = e.clientX;
630
+ const startY = e.clientY;
631
+
632
+ const onMove = (me: MouseEvent) => {
633
+ const dx = me.clientX - startX;
634
+ const dy = me.clientY - startY;
635
+ if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
636
+ document.removeEventListener('mousemove', onMove);
637
+ document.removeEventListener('mouseup', onUp);
638
+ this.startDrag(exitId, scope, toId, me);
639
+ }
640
+ };
641
+
642
+ const onUp = () => {
643
+ document.removeEventListener('mousemove', onMove);
644
+ document.removeEventListener('mouseup', onUp);
645
+ };
646
+
647
+ document.addEventListener('mousemove', onMove);
648
+ document.addEventListener('mouseup', onUp);
649
+ };
650
+ arrowEl.addEventListener('mousedown', onArrowMouseDown);
651
+
652
+ // Mark the exit element as connected
653
+ const exitEl = document.getElementById(exitId);
654
+ if (exitEl) {
655
+ exitEl.classList.add('connected');
656
+ }
657
+
658
+ // Create activity overlay if activity data exists for this segment
659
+ if (this.activityData) {
660
+ const activityKey = `${exitId}:${toId}`;
661
+ const count = this.activityData.segments[activityKey];
662
+ if (count && count > 0) {
663
+ const overlayEl = this.createOverlayElement(count, activityKey);
664
+ this.canvas.appendChild(overlayEl);
665
+ this.overlays.set(exitId, overlayEl);
666
+ this.updateOverlayPosition(exitId);
667
+ }
668
+ }
669
+
670
+ return true;
671
+ }
672
+
673
+ private updateConnectionSVG(exitId: string) {
674
+ const conn = this.connections.get(exitId);
675
+ if (!conn) return;
676
+
677
+ const endpoints = this.getConnectionEndpoints(conn.fromId, conn.toId);
678
+ if (!endpoints) return;
679
+
680
+ this.updateSVGPath(
681
+ conn.pathEl,
682
+ conn.arrowEl,
683
+ endpoints.sourceX,
684
+ endpoints.sourceY,
685
+ endpoints.targetX,
686
+ endpoints.targetY,
687
+ endpoints.targetFace
688
+ );
689
+ this.updateOverlayPosition(exitId);
690
+ }
691
+
692
+ private removeConnectionSVG(exitId: string) {
693
+ const conn = this.connections.get(exitId);
694
+ if (!conn) return;
695
+
696
+ const overlay = this.overlays.get(exitId);
697
+ if (overlay) {
698
+ overlay.remove();
699
+ this.overlays.delete(exitId);
700
+ }
701
+
702
+ conn.svgEl.remove();
703
+ this.connections.delete(exitId);
704
+ }
705
+
706
+ // --- Repaint ---
707
+
708
+ public repaintEverything() {
709
+ this.connections.forEach((_conn, exitId) => {
710
+ this.updateConnectionSVG(exitId);
711
+ });
712
+ }
713
+
714
+ public revalidate(ids: string[]) {
715
+ // Find all connections directly involving the given IDs
716
+ const directExits: string[] = [];
717
+ const affectedTargets = new Set<string>();
718
+
719
+ this.connections.forEach((conn, exitId) => {
720
+ if (
721
+ ids.includes(conn.fromId) ||
722
+ ids.includes(conn.toId) ||
723
+ ids.includes(conn.scope)
724
+ ) {
725
+ directExits.push(exitId);
726
+ affectedTargets.add(conn.toId);
727
+ }
728
+ });
729
+
730
+ // Also repaint sibling connections that share a target
731
+ // (so anchor distribution stays correct during drag)
732
+ const allExitsToRepaint = new Set(directExits);
733
+ this.connections.forEach((conn, exitId) => {
734
+ if (affectedTargets.has(conn.toId)) {
735
+ allExitsToRepaint.add(exitId);
736
+ }
737
+ });
738
+
739
+ allExitsToRepaint.forEach((exitId) => {
740
+ this.updateConnectionSVG(exitId);
741
+ });
742
+ }
743
+
744
+ // --- Connection removal ---
745
+
746
+ public forgetNode(nodeId: string) {
747
+ // Remove all connections where this node is source or target
748
+ const toRemove: string[] = [];
749
+ this.connections.forEach((conn, exitId) => {
750
+ if (conn.scope === nodeId || conn.toId === nodeId) {
751
+ toRemove.push(exitId);
752
+ }
753
+ });
754
+ toRemove.forEach((exitId) => this.removeConnectionSVG(exitId));
755
+
756
+ // Remove source listeners for exits of this node
757
+ const exitEls = document.getElementById(nodeId)?.querySelectorAll('.exit');
758
+ if (exitEls) {
759
+ exitEls.forEach((el) => {
760
+ const id = el.id;
761
+ if (this.sources.has(id)) {
762
+ this.sources.get(id)();
763
+ this.sources.delete(id);
764
+ }
233
765
  });
766
+ }
767
+ }
768
+
769
+ public removeNodeConnections(nodeId: string, exitIds?: string[]) {
770
+ // Only remove outbound connections from this node's exits.
771
+ // Inbound connections are managed by their source nodes and
772
+ // will repaint correctly on the next revalidate.
773
+ const exits =
774
+ exitIds ||
775
+ Array.from(
776
+ document.getElementById(nodeId)?.querySelectorAll('.exit') || []
777
+ ).map((el) => el.id);
778
+
779
+ exits.forEach((exitId) => this.removeConnectionSVG(exitId));
780
+ }
234
781
 
235
- // Force a repaint to ensure connections are positioned correctly
236
- // especially after bulk updates or view switching
237
- window.requestAnimationFrame(() => {
238
- if (this.jsPlumb) {
239
- this.jsPlumb.repaintEverything();
782
+ public removeExitConnection(exitId: string): boolean {
783
+ if (!this.connections.has(exitId)) return false;
784
+ this.removeConnectionSVG(exitId);
785
+ return true;
786
+ }
787
+
788
+ public removeAllEndpoints(nodeId: string) {
789
+ // Remove source listeners for this node's exits
790
+ const exitEls = document.getElementById(nodeId)?.querySelectorAll('.exit');
791
+ if (exitEls) {
792
+ exitEls.forEach((el) => {
793
+ const id = el.id;
794
+ if (this.sources.has(id)) {
795
+ this.sources.get(id)();
796
+ this.sources.delete(id);
240
797
  }
241
798
  });
242
- }, 0);
799
+ }
243
800
  }
244
801
 
245
- public connectIds(scope: string, fromId: string, toId: string) {
246
- this.pendingConnections.push({ scope, fromId, toId });
247
- this.processPendingConnections();
802
+ // --- Connection state ---
803
+
804
+ public setConnectionRemovingState(
805
+ exitId: string,
806
+ isRemoving: boolean
807
+ ): boolean {
808
+ const conn = this.connections.get(exitId);
809
+ if (!conn) return false;
810
+
811
+ if (isRemoving) {
812
+ conn.svgEl.classList.add('removing');
813
+ } else {
814
+ conn.svgEl.classList.remove('removing');
815
+ }
816
+ return true;
248
817
  }
249
818
 
819
+ // --- Activity overlays ---
820
+
250
821
  public setActivityData(
251
822
  activityData: { segments: { [key: string]: number } } | null
252
823
  ) {
253
824
  this.activityData = activityData;
254
- // Clear recent contacts cache when activity data changes
255
825
  this.clearRecentContactsCache();
256
826
  this.updateActivityOverlays();
257
827
  }
258
828
 
259
829
  private updateActivityOverlays() {
260
- if (!this.jsPlumb || !this.activityData) {
830
+ if (!this.activityData) {
831
+ this.overlays.forEach((el) => el.remove());
832
+ this.overlays.clear();
261
833
  return;
262
834
  }
263
835
 
264
- // Get all connections
265
- const connections = this.jsPlumb.getConnections();
836
+ const activeExitIds = new Set<string>();
266
837
 
267
- connections.forEach((connection: any) => {
268
- // Get the source exit element
269
- const sourceElement = connection.source;
270
- if (!sourceElement) {
271
- return;
272
- }
273
-
274
- // Get destination node
275
- const targetElement = connection.target;
276
- if (!targetElement) {
277
- return;
278
- }
279
-
280
- // Create activity key: exitUuid:destinationUuid
281
- const exitUuid = sourceElement.id;
282
- const destinationUuid = targetElement.id;
283
- const activityKey = `${exitUuid}:${destinationUuid}`;
284
-
285
- // Get activity count for this segment
838
+ this.connections.forEach((conn, exitId) => {
839
+ const activityKey = `${conn.fromId}:${conn.toId}`;
286
840
  const count = this.activityData.segments[activityKey];
287
841
 
288
- // Remove existing activity overlays
289
- connection.removeOverlay('activity-label');
290
-
291
- // Add new overlay if there's activity
292
842
  if (count && count > 0) {
293
- const overlay = connection.addOverlay({
294
- type: 'Label',
295
- options: {
296
- label: count.toLocaleString(),
297
- id: 'activity-label',
298
- cssClass: 'activity-overlay',
299
- location: 20 // Fixed pixel distance from the start (exit point)
300
- }
301
- });
843
+ activeExitIds.add(exitId);
844
+ let overlayEl = this.overlays.get(exitId);
845
+
846
+ if (!overlayEl) {
847
+ overlayEl = this.createOverlayElement(count, activityKey);
848
+ this.canvas.appendChild(overlayEl);
849
+ this.overlays.set(exitId, overlayEl);
850
+ } else {
851
+ overlayEl.textContent = count.toLocaleString();
852
+ overlayEl.setAttribute('data-activity-key', activityKey);
853
+ }
302
854
 
303
- // Add hover events for recent contacts popup
304
- // Use setTimeout to ensure the overlay is fully rendered
305
- setTimeout(() => {
306
- // Try multiple ways to get the overlay element
307
- let overlayElement =
308
- overlay.canvas || overlay.element || overlay.getElement?.();
309
-
310
- // If still not found, query the DOM directly
311
- if (!overlayElement) {
312
- const overlays = connection.getOverlays();
313
- if (Array.isArray(overlays)) {
314
- for (const ovl of overlays) {
315
- if (ovl.id === 'activity-label') {
316
- overlayElement =
317
- ovl.canvas || ovl.element || ovl.getElement?.();
318
- break;
319
- }
320
- }
321
- }
322
- }
855
+ this.updateOverlayPosition(exitId);
856
+ }
857
+ });
323
858
 
324
- // Also try querying by CSS class
325
- if (!overlayElement && connection.canvas) {
326
- overlayElement =
327
- connection.canvas.querySelector('.activity-overlay');
328
- }
859
+ // Remove overlays for connections that no longer have activity
860
+ this.overlays.forEach((el, exitId) => {
861
+ if (!activeExitIds.has(exitId)) {
862
+ el.remove();
863
+ this.overlays.delete(exitId);
864
+ }
865
+ });
866
+ }
329
867
 
330
- if (overlayElement) {
331
- overlayElement.style.cursor = 'pointer';
332
- overlayElement.setAttribute('data-activity-key', activityKey);
333
- overlayElement.addEventListener('mouseenter', () => {
334
- // Don't show recent contacts when simulator is active
335
- const store = getStore();
336
- if (store?.getState().simulatorActive) {
337
- return;
338
- }
339
-
340
- // Get flow UUID from the editor element
341
- const editor = document.querySelector('temba-flow-editor') as any;
342
- const flowUuid = editor?.definition?.uuid;
343
- if (flowUuid) {
344
- // Start fetching immediately
345
- this.fetchRecentContacts(activityKey, flowUuid);
346
-
347
- // But delay showing the popup by half a second
348
- this.showContactsTimeout = window.setTimeout(() => {
349
- this.showRecentContacts(activityKey, flowUuid);
350
- }, 500);
351
- }
352
- });
353
- overlayElement.addEventListener('mouseleave', () => {
354
- // Cancel the show timeout if still pending
355
- if (this.showContactsTimeout) {
356
- clearTimeout(this.showContactsTimeout);
357
- this.showContactsTimeout = null;
358
- }
359
- this.hoveredActivityKey = null;
360
- this.hideRecentContacts();
361
- });
362
- }
363
- }, 50);
868
+ private createOverlayElement(
869
+ count: number,
870
+ activityKey: string
871
+ ): HTMLElement {
872
+ const el = document.createElement('div');
873
+ el.className = 'activity-overlay';
874
+ el.textContent = count.toLocaleString();
875
+ el.setAttribute('data-activity-key', activityKey);
876
+
877
+ el.addEventListener('mouseenter', () => {
878
+ const flowUuid = this.getFlowUuid();
879
+ if (flowUuid) {
880
+ this.fetchRecentContacts(activityKey, flowUuid);
881
+ this.showContactsTimeout = window.setTimeout(() => {
882
+ this.showRecentContacts(activityKey, flowUuid);
883
+ }, 500);
364
884
  }
365
885
  });
366
886
 
367
- // Force repaint to ensure overlays are positioned correctly
368
- this.repaintEverything();
887
+ el.addEventListener('mouseleave', () => {
888
+ if (this.showContactsTimeout) {
889
+ clearTimeout(this.showContactsTimeout);
890
+ this.showContactsTimeout = null;
891
+ }
892
+ this.hoveredActivityKey = null;
893
+ this.hideRecentContacts();
894
+ });
895
+
896
+ return el;
369
897
  }
370
898
 
371
- private findOverlayElement(activityKey: string): HTMLElement | null {
372
- // Find overlay by data attribute
373
- const overlays = document.querySelectorAll('.activity-overlay');
374
- for (const overlay of overlays) {
375
- if (overlay.getAttribute('data-activity-key') === activityKey) {
376
- return overlay as HTMLElement;
377
- }
378
- }
379
- return null;
899
+ private updateOverlayPosition(exitId: string) {
900
+ const overlayEl = this.overlays.get(exitId);
901
+ const conn = this.connections.get(exitId);
902
+ if (!overlayEl || !conn) return;
903
+
904
+ const endpoints = this.getConnectionEndpoints(conn.fromId, conn.toId);
905
+ if (!endpoints) return;
906
+
907
+ overlayEl.style.position = 'absolute';
908
+ overlayEl.style.left = `${endpoints.sourceX}px`;
909
+ overlayEl.style.top = `${endpoints.sourceY + EXIT_STUB / 2}px`;
910
+ overlayEl.style.transform = 'translate(-50%, -50%)';
911
+ }
912
+
913
+ private getFlowUuid(): string | null {
914
+ return this.editor?.definition?.uuid || null;
380
915
  }
381
916
 
917
+ // --- Recent contacts ---
918
+
382
919
  private async fetchRecentContacts(activityKey: string, flowUuid: string) {
383
- // Skip if already cached or currently fetching
384
920
  if (
385
921
  this.recentContactsCache[activityKey] ||
386
922
  this.pendingFetches[activityKey]
@@ -388,35 +924,22 @@ export class Plumber {
388
924
  return;
389
925
  }
390
926
 
391
- // Cancel any pending fetch for this key
392
- if (this.pendingFetches[activityKey]) {
393
- this.pendingFetches[activityKey].abort();
394
- }
395
-
396
- // Fetch recent contacts from endpoint
397
927
  const controller = new AbortController();
398
928
  this.pendingFetches[activityKey] = controller;
399
929
 
400
930
  try {
401
- // Parse exit UUID and destination UUID from activity key
402
931
  const [exitUuid, destinationUuid] = activityKey.split(':');
403
-
404
932
  const endpoint = `/flow/recent_contacts/${flowUuid}/${exitUuid}/${destinationUuid}/`;
405
-
406
- const response = await fetch(endpoint, {
407
- signal: controller.signal
408
- });
933
+ const response = await fetch(endpoint, { signal: controller.signal });
409
934
 
410
935
  if (!response.ok) {
411
936
  throw new Error(`HTTP error! status: ${response.status}`);
412
937
  }
413
938
 
414
939
  const data = await response.json();
415
- // API returns array directly, not wrapped in results
416
- const recentContacts = Array.isArray(data) ? data : data.results || [];
417
-
418
- // Cache the results
419
- this.recentContactsCache[activityKey] = recentContacts;
940
+ this.recentContactsCache[activityKey] = Array.isArray(data)
941
+ ? data
942
+ : data.results || [];
420
943
  } catch (error) {
421
944
  if ((error as Error).name !== 'AbortError') {
422
945
  console.error('Failed to fetch recent contacts:', error);
@@ -427,19 +950,9 @@ export class Plumber {
427
950
  }
428
951
 
429
952
  private async showRecentContacts(activityKey: string, flowUuid: string) {
430
- // Don't show recent contacts when simulator is active
431
- const store = getStore();
432
- if (store?.getState().simulatorActive) {
433
- return;
434
- }
953
+ const overlayElement = this.findOverlayForActivityKey(activityKey);
954
+ if (!overlayElement) return;
435
955
 
436
- // Find the overlay element fresh to avoid stale references
437
- const overlayElement = this.findOverlayElement(activityKey);
438
- if (!overlayElement) {
439
- console.warn('Could not find overlay element for activity:', activityKey);
440
- return;
441
- }
442
- // Clear any pending hide timeout
443
956
  if (this.hideContactsTimeout) {
444
957
  clearTimeout(this.hideContactsTimeout);
445
958
  this.hideContactsTimeout = null;
@@ -447,24 +960,14 @@ export class Plumber {
447
960
 
448
961
  this.hoveredActivityKey = activityKey;
449
962
 
450
- // Create popup if it doesn't exist
451
963
  if (!this.recentContactsPopup) {
452
964
  this.recentContactsPopup = document.createElement('div');
453
965
  this.recentContactsPopup.className = 'recent-contacts-popup';
454
- // Add inline styles to ensure visibility
455
966
  this.recentContactsPopup.style.position = 'absolute';
456
- this.recentContactsPopup.style.width = '200px';
457
- this.recentContactsPopup.style.background = '#f3f3f3';
458
- this.recentContactsPopup.style.borderRadius = '10px';
459
- this.recentContactsPopup.style.boxShadow =
460
- '0 1px 3px 1px rgba(130, 130, 130, 0.2)';
461
967
  this.recentContactsPopup.style.zIndex = '1015';
462
968
  this.recentContactsPopup.style.display = 'none';
463
969
  document.body.appendChild(this.recentContactsPopup);
464
- }
465
970
 
466
- // Add hover events to keep popup open (only needs to be done once)
467
- if (!this.recentContactsPopup.onmouseenter) {
468
971
  this.recentContactsPopup.onmouseenter = () => {
469
972
  if (this.hideContactsTimeout) {
470
973
  clearTimeout(this.hideContactsTimeout);
@@ -476,15 +979,12 @@ export class Plumber {
476
979
  this.hideRecentContacts();
477
980
  };
478
981
 
479
- // Add click event listener for contact names
480
982
  this.recentContactsPopup.onclick = (e: MouseEvent) => {
481
983
  const target = e.target as HTMLElement;
482
-
483
984
  if (target.classList.contains('contact-name')) {
484
985
  this.hideRecentContacts(false);
485
986
  const contactUuid = target.getAttribute('data-uuid');
486
987
  if (contactUuid) {
487
- // Fire custom event through editor
488
988
  this.editor.fireCustomEvent('temba-contact-clicked', {
489
989
  uuid: contactUuid
490
990
  });
@@ -493,44 +993,41 @@ export class Plumber {
493
993
  };
494
994
  }
495
995
 
496
- // Check cache first
497
996
  if (this.recentContactsCache[activityKey]) {
498
997
  this.renderRecentContactsPopup(this.recentContactsCache[activityKey]);
499
998
  this.positionPopup(overlayElement);
500
999
  } else {
501
- // Show loading state if data isn't ready yet
502
1000
  this.recentContactsPopup.innerHTML =
503
1001
  '<div class="no-contacts-message">Loading...</div>';
504
1002
  this.positionPopup(overlayElement);
505
-
506
- // Wait for the fetch to complete
507
1003
  await this.fetchRecentContacts(activityKey, flowUuid);
508
-
509
- // Render if still hovering over this activity
510
1004
  if (this.hoveredActivityKey === activityKey) {
511
- const contacts = this.recentContactsCache[activityKey] || [];
512
- this.renderRecentContactsPopup(contacts);
1005
+ this.renderRecentContactsPopup(
1006
+ this.recentContactsCache[activityKey] || []
1007
+ );
513
1008
  this.positionPopup(overlayElement);
514
1009
  }
515
1010
  }
516
1011
  }
517
1012
 
1013
+ private findOverlayForActivityKey(activityKey: string): HTMLElement | null {
1014
+ for (const [, el] of this.overlays) {
1015
+ if (el.getAttribute('data-activity-key') === activityKey) {
1016
+ return el;
1017
+ }
1018
+ }
1019
+ return null;
1020
+ }
1021
+
518
1022
  private positionPopup(overlayElement: HTMLElement) {
519
1023
  if (!this.recentContactsPopup) return;
520
-
521
- // Position popup near the overlay
522
1024
  const rect = overlayElement.getBoundingClientRect();
523
1025
  this.recentContactsPopup.style.left = `${rect.left + window.scrollX}px`;
524
1026
  this.recentContactsPopup.style.top = `${
525
1027
  rect.bottom + window.scrollY + 5
526
1028
  }px`;
527
-
528
- // Remove inline display style so CSS class can work
529
1029
  this.recentContactsPopup.style.display = '';
530
-
531
- // Trigger animation by adding class
532
1030
  this.recentContactsPopup.classList.remove('show');
533
- // Force reflow to restart animation
534
1031
  void this.recentContactsPopup.offsetWidth;
535
1032
  this.recentContactsPopup.classList.add('show');
536
1033
  }
@@ -538,42 +1035,34 @@ export class Plumber {
538
1035
  private renderRecentContactsPopup(recentContacts: any[]) {
539
1036
  if (!this.recentContactsPopup) return;
540
1037
 
541
- const hasContacts = recentContacts.length > 0;
542
-
543
- if (!hasContacts) {
544
- // Simple message when no contacts
1038
+ if (recentContacts.length === 0) {
545
1039
  this.recentContactsPopup.innerHTML =
546
1040
  '<div class="no-contacts-message">No Recent Contacts</div>';
547
1041
  return;
548
1042
  }
549
1043
 
550
- let html = `<div class="popup-title">Recent Contacts</div>`;
551
-
1044
+ let html = '<div class="popup-title">Recent Contacts</div>';
552
1045
  recentContacts.forEach((contact: any) => {
553
- html += `<div class="contact-row">`;
1046
+ html += '<div class="contact-row">';
554
1047
  html += `<div class="contact-name" data-uuid="${contact.contact.uuid}">${contact.contact.name}</div>`;
555
1048
  if (contact.operand) {
556
1049
  html += `<div class="contact-operand">${contact.operand}</div>`;
557
1050
  }
558
1051
  if (contact.time) {
559
1052
  const time = new Date(contact.time);
560
- const now = new Date();
561
- const diffMs = now.getTime() - time.getTime();
1053
+ const diffMs = Date.now() - time.getTime();
562
1054
  const diffMins = Math.floor(diffMs / 60000);
563
1055
  const diffHours = Math.floor(diffMs / 3600000);
564
1056
  const diffDays = Math.floor(diffMs / 86400000);
565
-
566
1057
  let timeStr = '';
567
1058
  if (diffMins < 1) timeStr = 'just now';
568
1059
  else if (diffMins < 60) timeStr = `${diffMins}m ago`;
569
1060
  else if (diffHours < 24) timeStr = `${diffHours}h ago`;
570
1061
  else timeStr = `${diffDays}d ago`;
571
-
572
1062
  html += `<div class="contact-time">${timeStr}</div>`;
573
1063
  }
574
- html += `</div>`;
1064
+ html += '</div>';
575
1065
  });
576
-
577
1066
  this.recentContactsPopup.innerHTML = html;
578
1067
  }
579
1068
 
@@ -588,134 +1077,199 @@ export class Plumber {
588
1077
  }
589
1078
 
590
1079
  this.hideContactsTimeout = window.setTimeout(() => {
591
- // Check if we're still hovering over an activity
592
1080
  if (!this.hoveredActivityKey && this.recentContactsPopup) {
593
1081
  this.recentContactsPopup.classList.remove('show');
594
1082
  this.recentContactsPopup.style.display = 'none';
595
1083
  this.hoveredActivityKey = null;
596
1084
  }
597
- }, 200); // Small delay to allow moving between overlay and popup
1085
+ }, 200);
598
1086
  }
599
1087
 
600
1088
  public clearRecentContactsCache() {
601
1089
  this.recentContactsCache = {};
602
- // Cancel any pending fetches
603
1090
  Object.values(this.pendingFetches).forEach((controller) =>
604
1091
  controller.abort()
605
1092
  );
606
1093
  this.pendingFetches = {};
607
1094
  }
608
1095
 
609
- public repaintEverything() {
610
- if (this.jsPlumb) {
611
- this.jsPlumb.repaintEverything();
612
- }
613
- }
614
-
615
- public revalidate(ids: string[]) {
616
- if (!this.jsPlumb) return;
617
- this.jsPlumb.batch(() => {
618
- ids.forEach((id) => {
619
- const element = document.getElementById(id);
620
- if (element) {
621
- this.jsPlumb.revalidate(element);
622
- }
623
- });
624
- });
625
- }
1096
+ // --- Drag-and-drop ---
626
1097
 
627
- public reset() {
628
- if (this.connectionWait) {
629
- clearTimeout(this.connectionWait);
630
- this.connectionWait = null;
1098
+ private startDrag(
1099
+ exitId: string,
1100
+ scope: string,
1101
+ originalTargetId: string | null,
1102
+ e: MouseEvent
1103
+ ) {
1104
+ // Remove existing connection SVG for this exit (the connection is being dragged away)
1105
+ this.removeConnectionSVG(exitId);
1106
+
1107
+ const { svgEl, pathEl, arrowEl } = this.createSVGElement();
1108
+ svgEl.classList.add('dragging');
1109
+ // Ensure the drag SVG never intercepts mouse events (e.g. hover detection on nodes)
1110
+ pathEl.style.pointerEvents = 'none';
1111
+ arrowEl.style.pointerEvents = 'none';
1112
+ this.canvas.appendChild(svgEl);
1113
+
1114
+ // Calculate source point
1115
+ const exitEl = document.getElementById(exitId);
1116
+ if (!exitEl) {
1117
+ svgEl.remove();
1118
+ return;
631
1119
  }
632
- this.pendingConnections = [];
633
- this.jsPlumb.select().deleteAll();
634
- this.jsPlumb._managedElements = {};
635
- }
636
1120
 
637
- public forgetNode(nodeId: string) {
638
- if (!this.jsPlumb) return;
639
- const element = document.getElementById(nodeId);
640
- if (!element) return;
1121
+ const canvasRect = this.canvas.getBoundingClientRect();
1122
+ const exitRect = exitEl.getBoundingClientRect();
1123
+ const sourceX = exitRect.left + exitRect.width / 2 - canvasRect.left;
1124
+ const sourceY = exitRect.bottom - canvasRect.top;
641
1125
 
642
- this.jsPlumb.deleteConnectionsForElement(element);
643
- this.jsPlumb.removeAllEndpoints(element);
644
- this.jsPlumb.unmanage(element);
645
- }
1126
+ const aw = ARROW_HALF_WIDTH;
1127
+ const al = ARROW_LENGTH;
1128
+ const stubBehindArrow = 8;
646
1129
 
647
- public removeNodeConnections(nodeId: string, exitIds?: string[]) {
648
- if (!this.jsPlumb) return;
1130
+ // Update the drag path and arrow based on cursor position.
1131
+ // Arrow trails just before the cursor (between source and cursor).
1132
+ const cursorGap = CURSOR_GAP;
1133
+ const updateDragPath = (cx: number, cy: number) => {
1134
+ const goingUp = cy < sourceY;
649
1135
 
650
- const inbound = this.jsPlumb.select({ target: nodeId });
1136
+ let routeFace: TargetFace = 'top';
1137
+ if (goingUp) {
1138
+ routeFace = cx < sourceX ? 'left' : 'right';
1139
+ }
651
1140
 
652
- // Use provided exitIds or try to find them in DOM (fallback)
653
- const exits =
654
- exitIds ||
655
- Array.from(
656
- document.getElementById(nodeId)?.querySelectorAll('.exit') || []
657
- ).map((exit) => {
658
- return exit.id;
659
- }) ||
660
- [];
1141
+ // Position the arrow so its top edge sits just before the cursor.
1142
+ // "Top" = smallest Y on screen, which is the base for a downward
1143
+ // arrow and the tip for an upward arrow.
1144
+ let arrowBaseY: number;
1145
+ if (goingUp) {
1146
+ // Arrow points up: tip just below cursor, base below that
1147
+ arrowBaseY = cy + cursorGap + al;
1148
+ } else {
1149
+ // Arrow points down: base just above cursor, tip below
1150
+ arrowBaseY = cy - cursorGap;
1151
+ }
661
1152
 
662
- inbound.deleteAll();
663
- this.jsPlumb.select({ source: exits }).deleteAll();
664
- this.jsPlumb.selectEndpoints({ source: exits }).deleteAll();
665
- }
1153
+ const d = calculateFlowchartPath(
1154
+ sourceX,
1155
+ sourceY,
1156
+ cx,
1157
+ arrowBaseY,
1158
+ EXIT_STUB,
1159
+ goingUp ? 0 : stubBehindArrow,
1160
+ 5,
1161
+ routeFace
1162
+ );
1163
+ pathEl.setAttribute('d', d);
1164
+
1165
+ if (goingUp) {
1166
+ const tipY = cy + cursorGap;
1167
+ arrowEl.setAttribute(
1168
+ 'points',
1169
+ `${cx},${tipY} ${cx - aw},${arrowBaseY} ${cx + aw},${arrowBaseY}`
1170
+ );
1171
+ } else {
1172
+ const tipY = arrowBaseY + al;
1173
+ arrowEl.setAttribute(
1174
+ 'points',
1175
+ `${cx},${tipY} ${cx - aw},${arrowBaseY} ${cx + aw},${arrowBaseY}`
1176
+ );
1177
+ }
1178
+ };
666
1179
 
667
- public removeExitConnection(exitId: string) {
668
- if (!this.jsPlumb) return;
1180
+ // Initial path to cursor
1181
+ const cursorX = e.clientX - canvasRect.left;
1182
+ const cursorY = e.clientY - canvasRect.top;
1183
+ updateDragPath(cursorX, cursorY);
669
1184
 
670
- const exitElement = document.getElementById(exitId);
671
- if (!exitElement) return;
1185
+ this.connectionDragging = true;
672
1186
 
673
- // Get all connections from this exit
674
- const connections = this.jsPlumb.getConnections({ source: exitElement });
1187
+ const onMove = (me: MouseEvent) => {
1188
+ const cx = me.clientX - canvasRect.left;
1189
+ const cy = me.clientY - canvasRect.top;
1190
+ updateDragPath(cx, cy);
1191
+ };
675
1192
 
676
- // Remove the connections
677
- connections.forEach((connection) => {
678
- this.jsPlumb.deleteConnection(connection);
679
- });
1193
+ const onUp = (_me: MouseEvent) => {
1194
+ document.removeEventListener('mousemove', onMove);
1195
+ document.removeEventListener('mouseup', onUp);
680
1196
 
681
- return connections.length > 0;
1197
+ // Remove the drag SVG
1198
+ svgEl.remove();
1199
+ this.connectionDragging = false;
1200
+ this.dragState = null;
1201
+
1202
+ // Fire abort event so Editor can handle connection logic
1203
+ this.notifyListeners('connection:abort', {
1204
+ source: exitEl,
1205
+ sourceId: exitId,
1206
+ target: { id: originalTargetId },
1207
+ data: { nodeId: scope }
1208
+ });
1209
+ };
1210
+
1211
+ document.addEventListener('mousemove', onMove);
1212
+ document.addEventListener('mouseup', onUp);
1213
+
1214
+ this.dragState = {
1215
+ sourceId: exitId,
1216
+ scope,
1217
+ originalTargetId,
1218
+ svgEl,
1219
+ pathEl,
1220
+ arrowEl,
1221
+ onMove,
1222
+ onUp
1223
+ };
1224
+
1225
+ // Fire drag event so Editor knows a drag has started
1226
+ this.notifyListeners('connection:drag', {
1227
+ sourceId: exitId,
1228
+ sourceX,
1229
+ sourceY,
1230
+ data: { nodeId: scope },
1231
+ target: { id: originalTargetId }
1232
+ });
682
1233
  }
683
1234
 
684
- public removeAllEndpoints(nodeId: string) {
685
- if (!this.jsPlumb) return;
686
- const element = document.getElementById(nodeId);
687
- if (!element) return;
688
- this.jsPlumb.removeAllEndpoints(element, true);
689
- }
1235
+ // --- Reset ---
690
1236
 
691
- /**
692
- * Set the removing state for an exit's connection
693
- * @param exitId The ID of the exit whose connections should be marked as removing
694
- * @returns true if connections were found and updated, false otherwise
695
- */
696
- public setConnectionRemovingState(
697
- exitId: string,
698
- isRemoving: boolean
699
- ): boolean {
700
- if (!this.jsPlumb) return false;
1237
+ public reset() {
1238
+ if (this.connectionWait) {
1239
+ cancelAnimationFrame(this.connectionWait);
1240
+ this.connectionWait = null;
1241
+ }
1242
+ this.pendingConnections = [];
701
1243
 
702
- const exitElement = document.getElementById(exitId);
703
- if (!exitElement) return false;
1244
+ // Remove all connection SVGs
1245
+ this.connections.forEach((conn) => conn.svgEl.remove());
1246
+ this.connections.clear();
704
1247
 
705
- // Get all connections from this exit
706
- const connections = this.jsPlumb.getConnections({ source: exitElement });
1248
+ // Remove all activity overlays
1249
+ this.overlays.forEach((el) => el.remove());
1250
+ this.overlays.clear();
707
1251
 
708
- if (connections.length === 0) return false;
1252
+ // Clean up recent contacts popup
1253
+ this.hideRecentContacts(false);
1254
+ if (this.recentContactsPopup) {
1255
+ this.recentContactsPopup.remove();
1256
+ this.recentContactsPopup = null;
1257
+ }
1258
+ this.recentContactsCache = {};
1259
+ Object.values(this.pendingFetches).forEach((c) => c.abort());
1260
+ this.pendingFetches = {};
709
1261
 
710
- // Update the connections' CSS classes
711
- connections.forEach((connection) => {
712
- if (isRemoving) {
713
- connection.addClass('removing');
714
- } else {
715
- connection.removeClass('removing');
716
- }
717
- });
1262
+ // Remove all source listeners
1263
+ this.sources.forEach((cleanup) => cleanup());
1264
+ this.sources.clear();
718
1265
 
719
- return true;
1266
+ // Clean up any active drag
1267
+ if (this.dragState) {
1268
+ document.removeEventListener('mousemove', this.dragState.onMove);
1269
+ document.removeEventListener('mouseup', this.dragState.onUp);
1270
+ this.dragState.svgEl.remove();
1271
+ this.dragState = null;
1272
+ this.connectionDragging = false;
1273
+ }
720
1274
  }
721
1275
  }