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