@nyaruka/temba-components 0.142.2 → 0.142.3
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 +8 -0
- package/dist/temba-components.js +266 -192
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +8 -3
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +158 -9
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +473 -17
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +8 -8
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +89 -27
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/StickyNote.js +63 -3
- package/out-tsc/src/flow/StickyNote.js.map +1 -1
- package/out-tsc/src/flow/actions/send_msg.js +11 -8
- package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
- package/out-tsc/src/flow/nodes/split_by_subflow.js +3 -1
- package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
- package/out-tsc/src/flow/utils.js +1 -3
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/list/SortableList.js +104 -43
- package/out-tsc/src/list/SortableList.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +5 -1
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
- package/src/flow/CanvasMenu.ts +12 -4
- package/src/flow/CanvasNode.ts +185 -9
- package/src/flow/Editor.ts +552 -19
- package/src/flow/NodeEditor.ts +12 -12
- package/src/flow/Plumber.ts +101 -36
- package/src/flow/StickyNote.ts +76 -4
- package/src/flow/actions/send_msg.ts +11 -8
- package/src/flow/nodes/split_by_subflow.ts +3 -1
- package/src/flow/utils.ts +1 -3
- package/src/list/SortableList.ts +117 -47
- package/src/simulator/Simulator.ts +5 -1
|
@@ -96,6 +96,29 @@ export class Editor extends RapidElement {
|
|
|
96
96
|
-webkit-font-smoothing: antialiased;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/* On touch devices, disable native scroll-by-touch so canvas
|
|
100
|
+
drag draws a selection rectangle. Users scroll via scrollbars. */
|
|
101
|
+
#editor.touch-device {
|
|
102
|
+
touch-action: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#editor.touch-device::-webkit-scrollbar {
|
|
106
|
+
-webkit-appearance: none;
|
|
107
|
+
width: 12px;
|
|
108
|
+
height: 12px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#editor.touch-device::-webkit-scrollbar-thumb {
|
|
112
|
+
background: rgba(0, 0, 0, 0.3);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
border: 2px solid transparent;
|
|
115
|
+
background-clip: padding-box;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#editor.touch-device::-webkit-scrollbar-track {
|
|
119
|
+
background: rgba(0, 0, 0, 0.05);
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
temba-floating-tab {
|
|
100
123
|
--floating-tab-right: 15px;
|
|
101
124
|
}
|
|
@@ -126,6 +149,7 @@ export class Editor extends RapidElement {
|
|
|
126
149
|
#canvas > .draggable {
|
|
127
150
|
position: absolute;
|
|
128
151
|
z-index: 100;
|
|
152
|
+
touch-action: none;
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
#canvas > .dragging {
|
|
@@ -783,11 +807,20 @@ export class Editor extends RapidElement {
|
|
|
783
807
|
this.autoScrollAnimationId = null;
|
|
784
808
|
this.autoScrollDeltaX = 0;
|
|
785
809
|
this.autoScrollDeltaY = 0;
|
|
786
|
-
this.
|
|
810
|
+
this.lastPointerPos = null;
|
|
787
811
|
// Selection state
|
|
788
812
|
this.selectedItems = new Set();
|
|
789
813
|
this.isSelecting = false;
|
|
790
814
|
this.selectionBox = null;
|
|
815
|
+
// Touch device state
|
|
816
|
+
this.isTouchDevice = false;
|
|
817
|
+
this.isTwoFingerPanning = false;
|
|
818
|
+
this.twoFingerDidPan = false;
|
|
819
|
+
this.twoFingerStartMidX = 0;
|
|
820
|
+
this.twoFingerStartMidY = 0;
|
|
821
|
+
this.twoFingerOnCanvas = false;
|
|
822
|
+
this.lastPanX = 0;
|
|
823
|
+
this.lastPanY = 0;
|
|
791
824
|
this.targetId = null;
|
|
792
825
|
this.sourceId = null;
|
|
793
826
|
this.dragFromNodeId = null;
|
|
@@ -846,11 +879,20 @@ export class Editor extends RapidElement {
|
|
|
846
879
|
this.boundKeyDown = this.handleKeyDown.bind(this);
|
|
847
880
|
this.boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
|
|
848
881
|
this.boundWheel = this.handleWheel.bind(this);
|
|
882
|
+
this.boundTouchMove = this.handleTouchMove.bind(this);
|
|
883
|
+
this.boundTouchEnd = this.handleTouchEnd.bind(this);
|
|
884
|
+
this.boundTouchCancel = this.handleTouchCancel.bind(this);
|
|
885
|
+
this.boundCanvasTouchStart = this.handleCanvasTouchStart.bind(this);
|
|
849
886
|
}
|
|
850
887
|
firstUpdated(changes) {
|
|
851
888
|
super.firstUpdated(changes);
|
|
852
889
|
this.plumber = new Plumber(this.querySelector('#canvas'), this);
|
|
853
890
|
this.setupGlobalEventListeners();
|
|
891
|
+
// Eagerly detect touch capability so hover-only controls are visible
|
|
892
|
+
// from the start and scrollbar/touch-action CSS is applied immediately.
|
|
893
|
+
if (navigator.maxTouchPoints > 0) {
|
|
894
|
+
this.markTouchDevice();
|
|
895
|
+
}
|
|
854
896
|
this.updateZoomControlPositioning();
|
|
855
897
|
if (changes.has('flow')) {
|
|
856
898
|
getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
|
|
@@ -1188,9 +1230,13 @@ export class Editor extends RapidElement {
|
|
|
1188
1230
|
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
1189
1231
|
document.removeEventListener('mousedown', this.boundGlobalMouseDown);
|
|
1190
1232
|
document.removeEventListener('keydown', this.boundKeyDown);
|
|
1233
|
+
document.removeEventListener('touchmove', this.boundTouchMove);
|
|
1234
|
+
document.removeEventListener('touchend', this.boundTouchEnd);
|
|
1235
|
+
document.removeEventListener('touchcancel', this.boundTouchCancel);
|
|
1191
1236
|
const canvas = this.querySelector('#canvas');
|
|
1192
1237
|
if (canvas) {
|
|
1193
1238
|
canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1239
|
+
canvas.removeEventListener('touchstart', this.boundCanvasTouchStart);
|
|
1194
1240
|
}
|
|
1195
1241
|
const editor = this.querySelector('#editor');
|
|
1196
1242
|
if (editor) {
|
|
@@ -1205,9 +1251,24 @@ export class Editor extends RapidElement {
|
|
|
1205
1251
|
document.addEventListener('mouseup', this.boundMouseUp);
|
|
1206
1252
|
document.addEventListener('mousedown', this.boundGlobalMouseDown);
|
|
1207
1253
|
document.addEventListener('keydown', this.boundKeyDown);
|
|
1254
|
+
document.addEventListener('touchmove', this.boundTouchMove, {
|
|
1255
|
+
passive: false
|
|
1256
|
+
});
|
|
1257
|
+
document.addEventListener('touchend', this.boundTouchEnd);
|
|
1258
|
+
document.addEventListener('touchcancel', this.boundTouchCancel);
|
|
1259
|
+
// Fallback: on first touch, mark as touch device in case
|
|
1260
|
+
// navigator.maxTouchPoints wasn't detected in firstUpdated.
|
|
1261
|
+
const markTouchOnce = () => {
|
|
1262
|
+
this.markTouchDevice();
|
|
1263
|
+
document.removeEventListener('touchstart', markTouchOnce);
|
|
1264
|
+
};
|
|
1265
|
+
document.addEventListener('touchstart', markTouchOnce);
|
|
1208
1266
|
const canvas = this.querySelector('#canvas');
|
|
1209
1267
|
if (canvas) {
|
|
1210
1268
|
canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1269
|
+
canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
|
|
1270
|
+
passive: false
|
|
1271
|
+
});
|
|
1211
1272
|
}
|
|
1212
1273
|
const editor = this.querySelector('#editor');
|
|
1213
1274
|
if (editor) {
|
|
@@ -1301,6 +1362,62 @@ export class Editor extends RapidElement {
|
|
|
1301
1362
|
event.preventDefault();
|
|
1302
1363
|
event.stopPropagation();
|
|
1303
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Mirror of handleMouseDown for touch devices.
|
|
1367
|
+
* Sets up the same drag state so handleTouchMove/End can drive the drag.
|
|
1368
|
+
*/
|
|
1369
|
+
/* c8 ignore start -- touch-only handlers untestable in headless Chromium */
|
|
1370
|
+
/**
|
|
1371
|
+
* Mark the editor as a touch device — adds classes to #canvas and
|
|
1372
|
+
* #editor so touch-specific CSS activates (visible controls,
|
|
1373
|
+
* always-on scrollbars, touch-action: none).
|
|
1374
|
+
*/
|
|
1375
|
+
markTouchDevice() {
|
|
1376
|
+
var _b, _c;
|
|
1377
|
+
if (this.isTouchDevice)
|
|
1378
|
+
return;
|
|
1379
|
+
this.isTouchDevice = true;
|
|
1380
|
+
(_b = this.querySelector('#canvas')) === null || _b === void 0 ? void 0 : _b.classList.add('touch-device');
|
|
1381
|
+
(_c = this.querySelector('#editor')) === null || _c === void 0 ? void 0 : _c.classList.add('touch-device');
|
|
1382
|
+
}
|
|
1383
|
+
handleItemTouchStart(event) {
|
|
1384
|
+
this.markTouchDevice();
|
|
1385
|
+
if (this.isReadOnly())
|
|
1386
|
+
return;
|
|
1387
|
+
this.blurActiveContentEditable();
|
|
1388
|
+
const touch = event.touches[0];
|
|
1389
|
+
if (!touch)
|
|
1390
|
+
return;
|
|
1391
|
+
const element = event.currentTarget;
|
|
1392
|
+
const target = event.target;
|
|
1393
|
+
if (target.classList.contains('exit') ||
|
|
1394
|
+
target.closest('.exit') ||
|
|
1395
|
+
target.closest('.linked-name')) {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const uuid = element.getAttribute('uuid');
|
|
1399
|
+
const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
1400
|
+
const position = this.getPosition(uuid, type);
|
|
1401
|
+
if (!position)
|
|
1402
|
+
return;
|
|
1403
|
+
// Touch doesn't support Ctrl/Cmd selection — just clear
|
|
1404
|
+
if (!this.selectedItems.has(uuid)) {
|
|
1405
|
+
this.selectedItems.clear();
|
|
1406
|
+
}
|
|
1407
|
+
this.isMouseDown = true;
|
|
1408
|
+
this.dragStartPos = { x: touch.clientX, y: touch.clientY };
|
|
1409
|
+
this.startPos = { left: position.left, top: position.top };
|
|
1410
|
+
this.currentDragItem = {
|
|
1411
|
+
uuid,
|
|
1412
|
+
position,
|
|
1413
|
+
element,
|
|
1414
|
+
type
|
|
1415
|
+
};
|
|
1416
|
+
// Don't preventDefault here — allow the threshold check in touchmove
|
|
1417
|
+
// to decide whether this is a drag or a tap
|
|
1418
|
+
event.stopPropagation();
|
|
1419
|
+
}
|
|
1420
|
+
/* c8 ignore stop */
|
|
1304
1421
|
handleGlobalMouseDown(event) {
|
|
1305
1422
|
var _b;
|
|
1306
1423
|
if (isRightClick(event))
|
|
@@ -1890,6 +2007,72 @@ export class Editor extends RapidElement {
|
|
|
1890
2007
|
getStore().getState().updateCanvasPositions(positions);
|
|
1891
2008
|
}
|
|
1892
2009
|
}
|
|
2010
|
+
/* c8 ignore start -- touch-only handlers */
|
|
2011
|
+
/**
|
|
2012
|
+
* Find the temba-flow-node element at the given viewport coordinates.
|
|
2013
|
+
* Uses elementFromPoint which works for both mouse and touch input.
|
|
2014
|
+
*/
|
|
2015
|
+
findTargetNodeAt(clientX, clientY) {
|
|
2016
|
+
var _b;
|
|
2017
|
+
const el = document.elementFromPoint(clientX, clientY);
|
|
2018
|
+
return (_b = el === null || el === void 0 ? void 0 : el.closest('temba-flow-node')) !== null && _b !== void 0 ? _b : null;
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Handle touchstart on the canvas element. Mirrors handleGlobalMouseDown
|
|
2022
|
+
* + handleCanvasMouseDown for touch: starts selection on empty canvas,
|
|
2023
|
+
* and detects double-tap to show the context menu.
|
|
2024
|
+
*/
|
|
2025
|
+
handleCanvasTouchStart(event) {
|
|
2026
|
+
var _b;
|
|
2027
|
+
this.markTouchDevice();
|
|
2028
|
+
const touch = event.touches[0];
|
|
2029
|
+
if (!touch)
|
|
2030
|
+
return;
|
|
2031
|
+
// Only handle touches directly on canvas/grid (not on nodes)
|
|
2032
|
+
const target = event.target;
|
|
2033
|
+
if (target.closest('.draggable'))
|
|
2034
|
+
return;
|
|
2035
|
+
if (target.id !== 'canvas' && target.id !== 'grid')
|
|
2036
|
+
return;
|
|
2037
|
+
// Two-finger touch on canvas — record start position and enter the
|
|
2038
|
+
// two-finger state immediately (even before any touchmove). If the
|
|
2039
|
+
// fingers lift without panning, we show the context menu (handleTouchEnd).
|
|
2040
|
+
if (event.touches.length >= 2) {
|
|
2041
|
+
// Cancel any single-finger selection that the first touch started
|
|
2042
|
+
this.canvasMouseDown = false;
|
|
2043
|
+
this.isSelecting = false;
|
|
2044
|
+
this.selectionBox = null;
|
|
2045
|
+
this.isTwoFingerPanning = true;
|
|
2046
|
+
this.twoFingerOnCanvas = true;
|
|
2047
|
+
this.twoFingerDidPan = false;
|
|
2048
|
+
this.twoFingerStartMidX =
|
|
2049
|
+
(event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
2050
|
+
this.twoFingerStartMidY =
|
|
2051
|
+
(event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
2052
|
+
this.lastPanX = this.twoFingerStartMidX;
|
|
2053
|
+
this.lastPanY = this.twoFingerStartMidY;
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
// Start selection box (mirrors handleCanvasMouseDown)
|
|
2057
|
+
if (this.isReadOnly())
|
|
2058
|
+
return;
|
|
2059
|
+
this.canvasMouseDown = true;
|
|
2060
|
+
this.dragStartPos = { x: touch.clientX, y: touch.clientY };
|
|
2061
|
+
const canvasRect = (_b = this.querySelector('#canvas')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
|
|
2062
|
+
if (canvasRect) {
|
|
2063
|
+
this.selectedItems.clear();
|
|
2064
|
+
const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
|
|
2065
|
+
const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
|
|
2066
|
+
this.selectionBox = {
|
|
2067
|
+
startX: relativeX,
|
|
2068
|
+
startY: relativeY,
|
|
2069
|
+
endX: relativeX,
|
|
2070
|
+
endY: relativeY
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
event.preventDefault();
|
|
2074
|
+
}
|
|
2075
|
+
/* c8 ignore stop */
|
|
1893
2076
|
handleMouseMove(event) {
|
|
1894
2077
|
// Handle selection box drawing
|
|
1895
2078
|
if (this.canvasMouseDown && !this.isMouseDown) {
|
|
@@ -1962,7 +2145,7 @@ export class Editor extends RapidElement {
|
|
|
1962
2145
|
// Handle item dragging
|
|
1963
2146
|
if (!this.isMouseDown || !this.currentDragItem)
|
|
1964
2147
|
return;
|
|
1965
|
-
this.
|
|
2148
|
+
this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
|
|
1966
2149
|
const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
|
|
1967
2150
|
const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
|
|
1968
2151
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
@@ -1977,14 +2160,14 @@ export class Editor extends RapidElement {
|
|
|
1977
2160
|
}
|
|
1978
2161
|
}
|
|
1979
2162
|
updateDragPositions() {
|
|
1980
|
-
if (!this.currentDragItem || !this.
|
|
2163
|
+
if (!this.currentDragItem || !this.lastPointerPos)
|
|
1981
2164
|
return;
|
|
1982
2165
|
// Convert screen + scroll delta to canvas delta
|
|
1983
|
-
const deltaX = (this.
|
|
2166
|
+
const deltaX = (this.lastPointerPos.clientX -
|
|
1984
2167
|
this.dragStartPos.x +
|
|
1985
2168
|
this.autoScrollDeltaX) /
|
|
1986
2169
|
this.zoom;
|
|
1987
|
-
const deltaY = (this.
|
|
2170
|
+
const deltaY = (this.lastPointerPos.clientY -
|
|
1988
2171
|
this.dragStartPos.y +
|
|
1989
2172
|
this.autoScrollDeltaY) /
|
|
1990
2173
|
this.zoom;
|
|
@@ -2013,13 +2196,13 @@ export class Editor extends RapidElement {
|
|
|
2013
2196
|
if (!editor)
|
|
2014
2197
|
return;
|
|
2015
2198
|
const tick = () => {
|
|
2016
|
-
if (!this.isDragging || !this.
|
|
2199
|
+
if (!this.isDragging || !this.lastPointerPos) {
|
|
2017
2200
|
this.autoScrollAnimationId = null;
|
|
2018
2201
|
return;
|
|
2019
2202
|
}
|
|
2020
2203
|
const editorRect = editor.getBoundingClientRect();
|
|
2021
|
-
const mouseX = this.
|
|
2022
|
-
const mouseY = this.
|
|
2204
|
+
const mouseX = this.lastPointerPos.clientX;
|
|
2205
|
+
const mouseY = this.lastPointerPos.clientY;
|
|
2023
2206
|
let scrollDx = 0;
|
|
2024
2207
|
let scrollDy = 0;
|
|
2025
2208
|
// Left edge
|
|
@@ -2158,8 +2341,274 @@ export class Editor extends RapidElement {
|
|
|
2158
2341
|
this.canvasMouseDown = false;
|
|
2159
2342
|
this.autoScrollDeltaX = 0;
|
|
2160
2343
|
this.autoScrollDeltaY = 0;
|
|
2161
|
-
this.
|
|
2344
|
+
this.lastPointerPos = null;
|
|
2345
|
+
}
|
|
2346
|
+
/* c8 ignore start -- touch-only handlers */
|
|
2347
|
+
/**
|
|
2348
|
+
* Handle touch move on the document — mirrors handleMouseMove for
|
|
2349
|
+
* both connection dragging and node/sticky dragging on touch devices.
|
|
2350
|
+
*/
|
|
2351
|
+
handleTouchMove(event) {
|
|
2352
|
+
var _b;
|
|
2353
|
+
// --- Two-finger panning ---
|
|
2354
|
+
if (event.touches.length >= 2) {
|
|
2355
|
+
event.preventDefault();
|
|
2356
|
+
const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
2357
|
+
const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
2358
|
+
if (this.isTwoFingerPanning) {
|
|
2359
|
+
const dx = this.lastPanX - midX;
|
|
2360
|
+
const dy = this.lastPanY - midY;
|
|
2361
|
+
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
|
2362
|
+
this.twoFingerDidPan = true;
|
|
2363
|
+
}
|
|
2364
|
+
const editor = this.querySelector('#editor');
|
|
2365
|
+
if (editor) {
|
|
2366
|
+
editor.scrollBy(dx, dy);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
// Cancel any in-progress single-finger actions
|
|
2370
|
+
this.canvasMouseDown = false;
|
|
2371
|
+
this.isSelecting = false;
|
|
2372
|
+
this.selectionBox = null;
|
|
2373
|
+
this.isTwoFingerPanning = true;
|
|
2374
|
+
this.lastPanX = midX;
|
|
2375
|
+
this.lastPanY = midY;
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
const touch = event.touches[0];
|
|
2379
|
+
if (!touch)
|
|
2380
|
+
return;
|
|
2381
|
+
// --- Selection box drawing ---
|
|
2382
|
+
if (this.canvasMouseDown && !this.isMouseDown) {
|
|
2383
|
+
event.preventDefault();
|
|
2384
|
+
this.isSelecting = true;
|
|
2385
|
+
const canvasRect = (_b = this.querySelector('#canvas')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
|
|
2386
|
+
if (canvasRect && this.selectionBox) {
|
|
2387
|
+
this.selectionBox = {
|
|
2388
|
+
...this.selectionBox,
|
|
2389
|
+
endX: (touch.clientX - canvasRect.left) / this.zoom,
|
|
2390
|
+
endY: (touch.clientY - canvasRect.top) / this.zoom
|
|
2391
|
+
};
|
|
2392
|
+
this.updateSelectedItemsFromBox();
|
|
2393
|
+
}
|
|
2394
|
+
this.requestUpdate();
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
// --- Connection dragging ---
|
|
2398
|
+
if (this.plumber.connectionDragging) {
|
|
2399
|
+
event.preventDefault();
|
|
2400
|
+
const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
|
|
2401
|
+
// Clear previous target styles
|
|
2402
|
+
document.querySelectorAll('temba-flow-node').forEach((node) => {
|
|
2403
|
+
node.classList.remove('connection-target-valid', 'connection-target-invalid');
|
|
2404
|
+
});
|
|
2405
|
+
if (targetNode) {
|
|
2406
|
+
this.targetId = targetNode.getAttribute('uuid');
|
|
2407
|
+
this.isValidTarget = this.targetId !== this.dragFromNodeId;
|
|
2408
|
+
if (this.isValidTarget) {
|
|
2409
|
+
targetNode.classList.add('connection-target-valid');
|
|
2410
|
+
}
|
|
2411
|
+
else {
|
|
2412
|
+
targetNode.classList.add('connection-target-invalid');
|
|
2413
|
+
}
|
|
2414
|
+
this.connectionPlaceholder = null;
|
|
2415
|
+
}
|
|
2416
|
+
else {
|
|
2417
|
+
this.targetId = null;
|
|
2418
|
+
this.isValidTarget = true;
|
|
2419
|
+
const canvas = this.querySelector('#canvas');
|
|
2420
|
+
if (canvas) {
|
|
2421
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
2422
|
+
const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
|
|
2423
|
+
const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
|
|
2424
|
+
const placeholderWidth = 200;
|
|
2425
|
+
const placeholderHeight = 64;
|
|
2426
|
+
const arrowLength = ARROW_LENGTH;
|
|
2427
|
+
const cursorGap = CURSOR_GAP;
|
|
2428
|
+
const dragUp = this.connectionSourceY != null
|
|
2429
|
+
? relativeY < this.connectionSourceY
|
|
2430
|
+
: false;
|
|
2431
|
+
let top;
|
|
2432
|
+
if (dragUp) {
|
|
2433
|
+
top = relativeY + cursorGap - placeholderHeight;
|
|
2434
|
+
}
|
|
2435
|
+
else {
|
|
2436
|
+
top = relativeY - cursorGap + arrowLength;
|
|
2437
|
+
}
|
|
2438
|
+
this.connectionPlaceholder = {
|
|
2439
|
+
position: {
|
|
2440
|
+
left: relativeX - placeholderWidth / 2,
|
|
2441
|
+
top
|
|
2442
|
+
},
|
|
2443
|
+
visible: true,
|
|
2444
|
+
dragUp
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
this.requestUpdate();
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
// --- Node/sticky dragging ---
|
|
2452
|
+
if (!this.isMouseDown || !this.currentDragItem)
|
|
2453
|
+
return;
|
|
2454
|
+
this.lastPointerPos = { clientX: touch.clientX, clientY: touch.clientY };
|
|
2455
|
+
const deltaX = touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
|
|
2456
|
+
const deltaY = touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
|
|
2457
|
+
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
2458
|
+
if (!this.isDragging && distance > DRAG_THRESHOLD) {
|
|
2459
|
+
this.isDragging = true;
|
|
2460
|
+
this.startAutoScroll();
|
|
2461
|
+
}
|
|
2462
|
+
// Only prevent default scrolling once we're actually dragging.
|
|
2463
|
+
// Before the threshold, allow the browser to fire synthetic click
|
|
2464
|
+
// events for taps on buttons (remove, add-action, etc.).
|
|
2465
|
+
if (this.isDragging) {
|
|
2466
|
+
event.preventDefault();
|
|
2467
|
+
this.updateDragPositions();
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Handle touch end on the document — mirrors handleMouseUp for
|
|
2472
|
+
* both connection dragging and node/sticky dragging on touch devices.
|
|
2473
|
+
*/
|
|
2474
|
+
handleTouchEnd(event) {
|
|
2475
|
+
// --- Two-finger gesture end ---
|
|
2476
|
+
if (this.isTwoFingerPanning) {
|
|
2477
|
+
if (event.touches.length === 0) {
|
|
2478
|
+
const didPan = this.twoFingerDidPan;
|
|
2479
|
+
const onCanvas = this.twoFingerOnCanvas;
|
|
2480
|
+
const midX = this.twoFingerStartMidX;
|
|
2481
|
+
const midY = this.twoFingerStartMidY;
|
|
2482
|
+
// Reset state
|
|
2483
|
+
this.isTwoFingerPanning = false;
|
|
2484
|
+
this.twoFingerOnCanvas = false;
|
|
2485
|
+
this.twoFingerDidPan = false;
|
|
2486
|
+
// Two-finger tap (no pan) on canvas → show context menu
|
|
2487
|
+
if (!didPan && onCanvas) {
|
|
2488
|
+
this.showContextMenuAt(midX, midY);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const touch = event.changedTouches[0];
|
|
2494
|
+
// --- Selection box completion ---
|
|
2495
|
+
if (this.canvasMouseDown && this.isSelecting) {
|
|
2496
|
+
this.isSelecting = false;
|
|
2497
|
+
this.selectionBox = null;
|
|
2498
|
+
this.canvasMouseDown = false;
|
|
2499
|
+
this.requestUpdate();
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
// --- Canvas tap (no drag) — clear selection ---
|
|
2503
|
+
if (this.canvasMouseDown && !this.isSelecting) {
|
|
2504
|
+
this.canvasMouseDown = false;
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
// --- Connection dragging ---
|
|
2508
|
+
if (this.plumber.connectionDragging) {
|
|
2509
|
+
if (touch) {
|
|
2510
|
+
const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
|
|
2511
|
+
if (targetNode) {
|
|
2512
|
+
this.targetId = targetNode.getAttribute('uuid');
|
|
2513
|
+
this.isValidTarget = this.targetId !== this.dragFromNodeId;
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
// --- Node/sticky dragging ---
|
|
2519
|
+
if (!this.isMouseDown || !this.currentDragItem)
|
|
2520
|
+
return;
|
|
2521
|
+
this.stopAutoScroll();
|
|
2522
|
+
if (this.isDragging && touch) {
|
|
2523
|
+
const deltaX = (touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
|
|
2524
|
+
this.zoom;
|
|
2525
|
+
const deltaY = (touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
|
|
2526
|
+
this.zoom;
|
|
2527
|
+
const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
2528
|
+
this.selectedItems.size > 1
|
|
2529
|
+
? Array.from(this.selectedItems)
|
|
2530
|
+
: [this.currentDragItem.uuid];
|
|
2531
|
+
const newPositions = {};
|
|
2532
|
+
itemsToMove.forEach((uuid) => {
|
|
2533
|
+
const type = this.definition.nodes.find((node) => node.uuid === uuid)
|
|
2534
|
+
? 'node'
|
|
2535
|
+
: 'sticky';
|
|
2536
|
+
const position = this.getPosition(uuid, type);
|
|
2537
|
+
if (position) {
|
|
2538
|
+
const newLeft = position.left + deltaX;
|
|
2539
|
+
const newTop = position.top + deltaY;
|
|
2540
|
+
const snappedLeft = snapToGrid(newLeft);
|
|
2541
|
+
const snappedTop = snapToGrid(newTop);
|
|
2542
|
+
newPositions[uuid] = { left: snappedLeft, top: snappedTop };
|
|
2543
|
+
const element = this.querySelector(`[uuid="${uuid}"]`);
|
|
2544
|
+
if (element) {
|
|
2545
|
+
element.classList.remove('dragging');
|
|
2546
|
+
element.style.left = `${snappedLeft}px`;
|
|
2547
|
+
element.style.top = `${snappedTop}px`;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
if (Object.keys(newPositions).length > 0) {
|
|
2552
|
+
getStore().getState().updateCanvasPositions(newPositions);
|
|
2553
|
+
const nodeUuids = itemsToMove.filter((uuid) => this.definition.nodes.find((node) => node.uuid === uuid));
|
|
2554
|
+
if (nodeUuids.length > 0) {
|
|
2555
|
+
setTimeout(() => {
|
|
2556
|
+
this.checkCollisionsAndReflow(nodeUuids);
|
|
2557
|
+
}, 0);
|
|
2558
|
+
}
|
|
2559
|
+
else {
|
|
2560
|
+
setTimeout(() => {
|
|
2561
|
+
this.plumber.repaintEverything();
|
|
2562
|
+
}, 0);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
this.selectedItems.clear();
|
|
2566
|
+
}
|
|
2567
|
+
// Reset all drag state
|
|
2568
|
+
this.isDragging = false;
|
|
2569
|
+
this.isMouseDown = false;
|
|
2570
|
+
this.currentDragItem = null;
|
|
2571
|
+
this.canvasMouseDown = false;
|
|
2572
|
+
this.autoScrollDeltaX = 0;
|
|
2573
|
+
this.autoScrollDeltaY = 0;
|
|
2574
|
+
this.lastPointerPos = null;
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Handle touchcancel — reset all touch-related state so the editor
|
|
2578
|
+
* doesn't get stuck in a partial drag/selection mode.
|
|
2579
|
+
*/
|
|
2580
|
+
handleTouchCancel() {
|
|
2581
|
+
this.isTwoFingerPanning = false;
|
|
2582
|
+
this.isSelecting = false;
|
|
2583
|
+
this.selectionBox = null;
|
|
2584
|
+
this.canvasMouseDown = false;
|
|
2585
|
+
if (this.isDragging && this.currentDragItem) {
|
|
2586
|
+
// Remove dragging class from all moved items
|
|
2587
|
+
const itemsToReset = this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
2588
|
+
this.selectedItems.size > 1
|
|
2589
|
+
? Array.from(this.selectedItems)
|
|
2590
|
+
: [this.currentDragItem.uuid];
|
|
2591
|
+
itemsToReset.forEach((uuid) => {
|
|
2592
|
+
const el = this.querySelector(`[uuid="${uuid}"]`);
|
|
2593
|
+
if (el)
|
|
2594
|
+
el.classList.remove('dragging');
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
this.stopAutoScroll();
|
|
2598
|
+
this.isDragging = false;
|
|
2599
|
+
this.isMouseDown = false;
|
|
2600
|
+
this.currentDragItem = null;
|
|
2601
|
+
this.autoScrollDeltaX = 0;
|
|
2602
|
+
this.autoScrollDeltaY = 0;
|
|
2603
|
+
this.lastPointerPos = null;
|
|
2604
|
+
// Clear connection drag visual state
|
|
2605
|
+
document.querySelectorAll('temba-flow-node').forEach((node) => {
|
|
2606
|
+
node.classList.remove('connection-target-valid', 'connection-target-invalid');
|
|
2607
|
+
});
|
|
2608
|
+
this.connectionPlaceholder = null;
|
|
2609
|
+
this.requestUpdate();
|
|
2162
2610
|
}
|
|
2611
|
+
/* c8 ignore stop */
|
|
2163
2612
|
updateCanvasSize() {
|
|
2164
2613
|
var _b;
|
|
2165
2614
|
if (!this.definition)
|
|
@@ -2219,22 +2668,27 @@ export class Editor extends RapidElement {
|
|
|
2219
2668
|
// Prevent the default browser context menu
|
|
2220
2669
|
event.preventDefault();
|
|
2221
2670
|
event.stopPropagation();
|
|
2222
|
-
|
|
2671
|
+
this.showContextMenuAt(event.clientX, event.clientY);
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Show the canvas context menu at the given viewport coordinates.
|
|
2675
|
+
* Shared by right-click (mouse) and double-tap (touch).
|
|
2676
|
+
*/
|
|
2677
|
+
showContextMenuAt(clientX, clientY) {
|
|
2678
|
+
if (this.isReadOnly())
|
|
2679
|
+
return;
|
|
2223
2680
|
const canvas = this.querySelector('#canvas');
|
|
2224
|
-
if (!canvas)
|
|
2681
|
+
if (!canvas)
|
|
2225
2682
|
return;
|
|
2226
|
-
}
|
|
2227
2683
|
const canvasRect = canvas.getBoundingClientRect();
|
|
2228
|
-
const relativeX = (
|
|
2229
|
-
const relativeY = (
|
|
2230
|
-
// Snap position to grid
|
|
2684
|
+
const relativeX = (clientX - canvasRect.left) / this.zoom - 10;
|
|
2685
|
+
const relativeY = (clientY - canvasRect.top) / this.zoom - 10;
|
|
2231
2686
|
const snappedLeft = snapToGrid(relativeX);
|
|
2232
2687
|
const snappedTop = snapToGrid(relativeY);
|
|
2233
|
-
// Show the canvas menu at the mouse position (use viewport coordinates)
|
|
2234
2688
|
const canvasMenu = this.querySelector('temba-canvas-menu');
|
|
2235
2689
|
if (canvasMenu) {
|
|
2236
2690
|
const hasNodes = this.definition && this.definition.nodes.length > 0;
|
|
2237
|
-
canvasMenu.show(
|
|
2691
|
+
canvasMenu.show(clientX, clientY, {
|
|
2238
2692
|
x: snappedLeft,
|
|
2239
2693
|
y: snappedTop
|
|
2240
2694
|
}, true, hasNodes, this.flowType === 'message');
|
|
@@ -3776,6 +4230,7 @@ export class Editor extends RapidElement {
|
|
|
3776
4230
|
? 'flow-start'
|
|
3777
4231
|
: ''}"
|
|
3778
4232
|
@mousedown=${this.handleMouseDown.bind(this)}
|
|
4233
|
+
@touchstart=${this.handleItemTouchStart.bind(this)}
|
|
3779
4234
|
uuid=${node.uuid}
|
|
3780
4235
|
data-node-uuid=${node.uuid}
|
|
3781
4236
|
style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
|
|
@@ -3798,6 +4253,7 @@ export class Editor extends RapidElement {
|
|
|
3798
4253
|
? 'selected'
|
|
3799
4254
|
: ''}"
|
|
3800
4255
|
@mousedown=${this.handleMouseDown.bind(this)}
|
|
4256
|
+
@touchstart=${this.handleItemTouchStart.bind(this)}
|
|
3801
4257
|
style="left:${position.left}px; top:${position.top}px;"
|
|
3802
4258
|
uuid=${uuid}
|
|
3803
4259
|
.data=${sticky}
|