@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.
- package/CHANGELOG.md +9 -0
- package/dist/locales/es.js +5 -5
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +5 -5
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/locale-codes.js +2 -11
- package/dist/locales/locale-codes.js.map +1 -1
- package/dist/locales/pt.js +5 -5
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +815 -851
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js +23 -30
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +5 -3
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +6 -7
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +152 -235
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +757 -403
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/utils.js +138 -66
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/interfaces.js +1 -0
- package/out-tsc/src/interfaces.js.map +1 -1
- package/out-tsc/src/list/TicketList.js +4 -1
- package/out-tsc/src/list/TicketList.js.map +1 -1
- package/out-tsc/src/locales/es.js +5 -5
- package/out-tsc/src/locales/es.js.map +1 -1
- package/out-tsc/src/locales/fr.js +5 -5
- package/out-tsc/src/locales/fr.js.map +1 -1
- package/out-tsc/src/locales/locale-codes.js +2 -11
- package/out-tsc/src/locales/locale-codes.js.map +1 -1
- package/out-tsc/src/locales/pt.js +5 -5
- package/out-tsc/src/locales/pt.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +1 -0
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/out-tsc/test/temba-floating-tab.test.js +4 -6
- package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
- package/out-tsc/test/temba-flow-collision.test.js +221 -223
- package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
- package/out-tsc/test/temba-flow-editor.test.js +0 -2
- package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
- package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber.test.js +102 -93
- package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
- package/package.json +1 -1
- package/src/display/FloatingTab.ts +22 -31
- package/src/flow/CanvasMenu.ts +8 -3
- package/src/flow/CanvasNode.ts +6 -7
- package/src/flow/Editor.ts +184 -279
- package/src/flow/Plumber.ts +1011 -457
- package/src/flow/utils.ts +162 -84
- package/src/interfaces.ts +2 -1
- package/src/list/TicketList.ts +4 -1
- package/src/locales/es.ts +13 -18
- package/src/locales/fr.ts +13 -18
- package/src/locales/locale-codes.ts +2 -11
- package/src/locales/pt.ts +13 -18
- package/src/simulator/Simulator.ts +1 -0
- package/test/temba-floating-tab.test.ts +4 -6
- package/test/temba-flow-collision.test.ts +225 -303
- package/test/temba-flow-editor.test.ts +0 -2
- package/test/temba-flow-plumber-connections.test.ts +97 -97
- package/test/temba-flow-plumber.test.ts +116 -103
|
@@ -1,117 +1,134 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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
|
-
|
|
135
|
-
|
|
151
|
+
// --- Source/Target registration ---
|
|
152
|
+
makeSource(exitId) {
|
|
153
|
+
const element = document.getElementById(exitId);
|
|
136
154
|
if (!element)
|
|
137
155
|
return;
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
215
|
+
cancelAnimationFrame(this.connectionWait);
|
|
151
216
|
this.connectionWait = null;
|
|
152
217
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
}
|
|
578
|
+
}
|
|
200
579
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
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.
|
|
600
|
+
if (!this.activityData) {
|
|
601
|
+
this.overlays.forEach((el) => el.remove());
|
|
602
|
+
this.overlays.clear();
|
|
213
603
|
return;
|
|
214
604
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
783
|
+
let html = '<div class="popup-title">Recent Contacts</div>';
|
|
463
784
|
recentContacts.forEach((contact) => {
|
|
464
|
-
html +=
|
|
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
|
|
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 +=
|
|
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);
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
937
|
+
cancelAnimationFrame(this.connectionWait);
|
|
535
938
|
this.connectionWait = null;
|
|
536
939
|
}
|
|
537
940
|
this.pendingConnections = [];
|
|
538
|
-
|
|
539
|
-
this.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|