@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/temba-components.js +266 -192
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/flow/CanvasMenu.js +8 -3
  5. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasNode.js +158 -9
  7. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  8. package/out-tsc/src/flow/Editor.js +473 -17
  9. package/out-tsc/src/flow/Editor.js.map +1 -1
  10. package/out-tsc/src/flow/NodeEditor.js +8 -8
  11. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  12. package/out-tsc/src/flow/Plumber.js +89 -27
  13. package/out-tsc/src/flow/Plumber.js.map +1 -1
  14. package/out-tsc/src/flow/StickyNote.js +63 -3
  15. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  16. package/out-tsc/src/flow/actions/send_msg.js +11 -8
  17. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  18. package/out-tsc/src/flow/nodes/split_by_subflow.js +3 -1
  19. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
  20. package/out-tsc/src/flow/utils.js +1 -3
  21. package/out-tsc/src/flow/utils.js.map +1 -1
  22. package/out-tsc/src/list/SortableList.js +104 -43
  23. package/out-tsc/src/list/SortableList.js.map +1 -1
  24. package/out-tsc/src/simulator/Simulator.js +5 -1
  25. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  26. package/package.json +1 -1
  27. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  28. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  29. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  30. package/src/flow/CanvasMenu.ts +12 -4
  31. package/src/flow/CanvasNode.ts +185 -9
  32. package/src/flow/Editor.ts +552 -19
  33. package/src/flow/NodeEditor.ts +12 -12
  34. package/src/flow/Plumber.ts +101 -36
  35. package/src/flow/StickyNote.ts +76 -4
  36. package/src/flow/actions/send_msg.ts +11 -8
  37. package/src/flow/nodes/split_by_subflow.ts +3 -1
  38. package/src/flow/utils.ts +1 -3
  39. package/src/list/SortableList.ts +117 -47
  40. 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.lastMouseEvent = null;
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.lastMouseEvent = event;
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.lastMouseEvent)
2163
+ if (!this.currentDragItem || !this.lastPointerPos)
1981
2164
  return;
1982
2165
  // Convert screen + scroll delta to canvas delta
1983
- const deltaX = (this.lastMouseEvent.clientX -
2166
+ const deltaX = (this.lastPointerPos.clientX -
1984
2167
  this.dragStartPos.x +
1985
2168
  this.autoScrollDeltaX) /
1986
2169
  this.zoom;
1987
- const deltaY = (this.lastMouseEvent.clientY -
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.lastMouseEvent) {
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.lastMouseEvent.clientX;
2022
- const mouseY = this.lastMouseEvent.clientY;
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.lastMouseEvent = null;
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
- // Get canvas position
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 = (event.clientX - canvasRect.left) / this.zoom - 10;
2229
- const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
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(event.clientX, event.clientY, {
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}