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