@nyaruka/temba-components 0.142.1 → 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 (140) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/temba-components.js +953 -708
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/Icons.js +1 -0
  5. package/out-tsc/src/Icons.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasMenu.js +38 -38
  7. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  8. package/out-tsc/src/flow/CanvasNode.js +171 -17
  9. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  10. package/out-tsc/src/flow/Editor.js +491 -22
  11. package/out-tsc/src/flow/Editor.js.map +1 -1
  12. package/out-tsc/src/flow/NodeEditor.js +346 -10
  13. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  14. package/out-tsc/src/flow/NodeTypeSelector.js +2 -0
  15. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  16. package/out-tsc/src/flow/Plumber.js +92 -28
  17. package/out-tsc/src/flow/Plumber.js.map +1 -1
  18. package/out-tsc/src/flow/StickyNote.js +63 -3
  19. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  20. package/out-tsc/src/flow/actions/add_contact_urn.js +2 -6
  21. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -1
  22. package/out-tsc/src/flow/actions/enter_flow.js +2 -2
  23. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -1
  24. package/out-tsc/src/flow/actions/say_msg.js +2 -1
  25. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  26. package/out-tsc/src/flow/actions/send_broadcast.js +2 -6
  27. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -1
  28. package/out-tsc/src/flow/actions/send_email.js +2 -6
  29. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  30. package/out-tsc/src/flow/actions/send_msg.js +55 -35
  31. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  32. package/out-tsc/src/flow/actions/set_contact_channel.js +2 -1
  33. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  34. package/out-tsc/src/flow/actions/set_contact_field.js +4 -5
  35. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  36. package/out-tsc/src/flow/actions/set_contact_language.js +3 -3
  37. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -1
  38. package/out-tsc/src/flow/actions/set_contact_name.js +2 -1
  39. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -1
  40. package/out-tsc/src/flow/actions/set_contact_status.js +2 -1
  41. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  42. package/out-tsc/src/flow/actions/set_run_result.js +3 -3
  43. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  44. package/out-tsc/src/flow/actions/start_session.js +2 -2
  45. package/out-tsc/src/flow/actions/start_session.js.map +1 -1
  46. package/out-tsc/src/flow/nodes/split_by_llm.js +4 -5
  47. package/out-tsc/src/flow/nodes/split_by_llm.js.map +1 -1
  48. package/out-tsc/src/flow/nodes/split_by_resthook.js +3 -8
  49. package/out-tsc/src/flow/nodes/split_by_resthook.js.map +1 -1
  50. package/out-tsc/src/flow/nodes/split_by_subflow.js +4 -2
  51. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
  52. package/out-tsc/src/flow/nodes/split_by_webhook.js +25 -33
  53. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -1
  54. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -0
  55. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  56. package/out-tsc/src/flow/types.js.map +1 -1
  57. package/out-tsc/src/flow/utils.js +66 -0
  58. package/out-tsc/src/flow/utils.js.map +1 -1
  59. package/out-tsc/src/form/FieldRenderer.js +17 -2
  60. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  61. package/out-tsc/src/interfaces.js +1 -0
  62. package/out-tsc/src/interfaces.js.map +1 -1
  63. package/out-tsc/src/list/SortableList.js +104 -43
  64. package/out-tsc/src/list/SortableList.js.map +1 -1
  65. package/out-tsc/src/simulator/Simulator.js +6 -2
  66. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  67. package/out-tsc/test/temba-canvas-menu.test.js +13 -9
  68. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  69. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -1
  70. package/out-tsc/test/temba-node-editor.test.js +9 -10
  71. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  72. package/out-tsc/test/temba-node-type-selector.test.js +3 -3
  73. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  74. package/out-tsc/test/temba-simulator.test.js +2 -2
  75. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  76. package/package.json +1 -1
  77. package/screenshots/truth/actions/enter_flow/render/basic-flow.png +0 -0
  78. package/screenshots/truth/actions/enter_flow/render/long-flow-name.png +0 -0
  79. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  80. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  81. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  83. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  84. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  85. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  86. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  87. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  88. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  89. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  90. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  92. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  93. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  94. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  95. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  96. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  97. package/screenshots/truth/canvas-menu/open.png +0 -0
  98. package/screenshots/truth/node-type-selector/action-mode.png +0 -0
  99. package/screenshots/truth/node-type-selector/split-mode.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  103. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  104. package/src/Icons.ts +1 -0
  105. package/src/flow/CanvasMenu.ts +50 -43
  106. package/src/flow/CanvasNode.ts +201 -17
  107. package/src/flow/Editor.ts +585 -25
  108. package/src/flow/NodeEditor.ts +373 -10
  109. package/src/flow/NodeTypeSelector.ts +2 -0
  110. package/src/flow/Plumber.ts +104 -37
  111. package/src/flow/StickyNote.ts +76 -4
  112. package/src/flow/actions/add_contact_urn.ts +5 -6
  113. package/src/flow/actions/enter_flow.ts +2 -2
  114. package/src/flow/actions/say_msg.ts +2 -1
  115. package/src/flow/actions/send_broadcast.ts +2 -6
  116. package/src/flow/actions/send_email.ts +2 -6
  117. package/src/flow/actions/send_msg.ts +59 -38
  118. package/src/flow/actions/set_contact_channel.ts +5 -1
  119. package/src/flow/actions/set_contact_field.ts +10 -5
  120. package/src/flow/actions/set_contact_language.ts +6 -3
  121. package/src/flow/actions/set_contact_name.ts +5 -1
  122. package/src/flow/actions/set_contact_status.ts +5 -1
  123. package/src/flow/actions/set_run_result.ts +6 -3
  124. package/src/flow/actions/start_session.ts +2 -2
  125. package/src/flow/nodes/split_by_llm.ts +5 -5
  126. package/src/flow/nodes/split_by_resthook.ts +3 -8
  127. package/src/flow/nodes/split_by_subflow.ts +4 -2
  128. package/src/flow/nodes/split_by_webhook.ts +26 -34
  129. package/src/flow/nodes/wait_for_response.ts +1 -0
  130. package/src/flow/types.ts +25 -2
  131. package/src/flow/utils.ts +79 -1
  132. package/src/form/FieldRenderer.ts +32 -3
  133. package/src/interfaces.ts +1 -0
  134. package/src/list/SortableList.ts +117 -47
  135. package/src/simulator/Simulator.ts +6 -2
  136. package/test/temba-canvas-menu.test.ts +13 -9
  137. package/test/temba-flow-reflow.test.ts +4 -2
  138. package/test/temba-node-editor.test.ts +9 -10
  139. package/test/temba-node-type-selector.test.ts +3 -3
  140. package/test/temba-simulator.test.ts +2 -2
@@ -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 {
@@ -625,7 +649,7 @@ export class Editor extends RapidElement {
625
649
  top: 8px;
626
650
  right: 240px;
627
651
  padding: 6px 10px;
628
- z-index: 10000;
652
+ z-index: 4999;
629
653
  pointer-events: none;
630
654
  opacity: 0;
631
655
  transition: opacity 0.15s ease-in-out;
@@ -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}`);
@@ -914,7 +956,8 @@ export class Editor extends RapidElement {
914
956
  canvasMenu.show(menuX, menuY, {
915
957
  x: snappedPosition.left,
916
958
  y: snappedPosition.top
917
- }, false); // Don't show sticky note option for connection drops
959
+ }, false, // Don't show sticky note option for connection drops
960
+ false, this.flowType === 'message');
918
961
  }
919
962
  }
920
963
  // Request update to render the connection line
@@ -1187,9 +1230,13 @@ export class Editor extends RapidElement {
1187
1230
  document.removeEventListener('mouseup', this.boundMouseUp);
1188
1231
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
1189
1232
  document.removeEventListener('keydown', this.boundKeyDown);
1233
+ document.removeEventListener('touchmove', this.boundTouchMove);
1234
+ document.removeEventListener('touchend', this.boundTouchEnd);
1235
+ document.removeEventListener('touchcancel', this.boundTouchCancel);
1190
1236
  const canvas = this.querySelector('#canvas');
1191
1237
  if (canvas) {
1192
1238
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1239
+ canvas.removeEventListener('touchstart', this.boundCanvasTouchStart);
1193
1240
  }
1194
1241
  const editor = this.querySelector('#editor');
1195
1242
  if (editor) {
@@ -1204,9 +1251,24 @@ export class Editor extends RapidElement {
1204
1251
  document.addEventListener('mouseup', this.boundMouseUp);
1205
1252
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
1206
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);
1207
1266
  const canvas = this.querySelector('#canvas');
1208
1267
  if (canvas) {
1209
1268
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1269
+ canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
1270
+ passive: false
1271
+ });
1210
1272
  }
1211
1273
  const editor = this.querySelector('#editor');
1212
1274
  if (editor) {
@@ -1267,7 +1329,9 @@ export class Editor extends RapidElement {
1267
1329
  const element = event.currentTarget;
1268
1330
  // Only start dragging if clicking on the element itself, not on exits or other interactive elements
1269
1331
  const target = event.target;
1270
- if (target.classList.contains('exit') || target.closest('.exit')) {
1332
+ if (target.classList.contains('exit') ||
1333
+ target.closest('.exit') ||
1334
+ target.closest('.linked-name')) {
1271
1335
  return;
1272
1336
  }
1273
1337
  const uuid = element.getAttribute('uuid');
@@ -1298,6 +1362,62 @@ export class Editor extends RapidElement {
1298
1362
  event.preventDefault();
1299
1363
  event.stopPropagation();
1300
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 */
1301
1421
  handleGlobalMouseDown(event) {
1302
1422
  var _b;
1303
1423
  if (isRightClick(event))
@@ -1887,6 +2007,72 @@ export class Editor extends RapidElement {
1887
2007
  getStore().getState().updateCanvasPositions(positions);
1888
2008
  }
1889
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 */
1890
2076
  handleMouseMove(event) {
1891
2077
  // Handle selection box drawing
1892
2078
  if (this.canvasMouseDown && !this.isMouseDown) {
@@ -1959,7 +2145,7 @@ export class Editor extends RapidElement {
1959
2145
  // Handle item dragging
1960
2146
  if (!this.isMouseDown || !this.currentDragItem)
1961
2147
  return;
1962
- this.lastMouseEvent = event;
2148
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
1963
2149
  const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
1964
2150
  const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
1965
2151
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
@@ -1974,14 +2160,14 @@ export class Editor extends RapidElement {
1974
2160
  }
1975
2161
  }
1976
2162
  updateDragPositions() {
1977
- if (!this.currentDragItem || !this.lastMouseEvent)
2163
+ if (!this.currentDragItem || !this.lastPointerPos)
1978
2164
  return;
1979
2165
  // Convert screen + scroll delta to canvas delta
1980
- const deltaX = (this.lastMouseEvent.clientX -
2166
+ const deltaX = (this.lastPointerPos.clientX -
1981
2167
  this.dragStartPos.x +
1982
2168
  this.autoScrollDeltaX) /
1983
2169
  this.zoom;
1984
- const deltaY = (this.lastMouseEvent.clientY -
2170
+ const deltaY = (this.lastPointerPos.clientY -
1985
2171
  this.dragStartPos.y +
1986
2172
  this.autoScrollDeltaY) /
1987
2173
  this.zoom;
@@ -2010,13 +2196,13 @@ export class Editor extends RapidElement {
2010
2196
  if (!editor)
2011
2197
  return;
2012
2198
  const tick = () => {
2013
- if (!this.isDragging || !this.lastMouseEvent) {
2199
+ if (!this.isDragging || !this.lastPointerPos) {
2014
2200
  this.autoScrollAnimationId = null;
2015
2201
  return;
2016
2202
  }
2017
2203
  const editorRect = editor.getBoundingClientRect();
2018
- const mouseX = this.lastMouseEvent.clientX;
2019
- const mouseY = this.lastMouseEvent.clientY;
2204
+ const mouseX = this.lastPointerPos.clientX;
2205
+ const mouseY = this.lastPointerPos.clientY;
2020
2206
  let scrollDx = 0;
2021
2207
  let scrollDy = 0;
2022
2208
  // Left edge
@@ -2155,8 +2341,274 @@ export class Editor extends RapidElement {
2155
2341
  this.canvasMouseDown = false;
2156
2342
  this.autoScrollDeltaX = 0;
2157
2343
  this.autoScrollDeltaY = 0;
2158
- 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;
2159
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();
2610
+ }
2611
+ /* c8 ignore stop */
2160
2612
  updateCanvasSize() {
2161
2613
  var _b;
2162
2614
  if (!this.definition)
@@ -2216,25 +2668,30 @@ export class Editor extends RapidElement {
2216
2668
  // Prevent the default browser context menu
2217
2669
  event.preventDefault();
2218
2670
  event.stopPropagation();
2219
- // 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;
2220
2680
  const canvas = this.querySelector('#canvas');
2221
- if (!canvas) {
2681
+ if (!canvas)
2222
2682
  return;
2223
- }
2224
2683
  const canvasRect = canvas.getBoundingClientRect();
2225
- const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
2226
- const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
2227
- // Snap position to grid
2684
+ const relativeX = (clientX - canvasRect.left) / this.zoom - 10;
2685
+ const relativeY = (clientY - canvasRect.top) / this.zoom - 10;
2228
2686
  const snappedLeft = snapToGrid(relativeX);
2229
2687
  const snappedTop = snapToGrid(relativeY);
2230
- // Show the canvas menu at the mouse position (use viewport coordinates)
2231
2688
  const canvasMenu = this.querySelector('temba-canvas-menu');
2232
2689
  if (canvasMenu) {
2233
2690
  const hasNodes = this.definition && this.definition.nodes.length > 0;
2234
- canvasMenu.show(event.clientX, event.clientY, {
2691
+ canvasMenu.show(clientX, clientY, {
2235
2692
  x: snappedLeft,
2236
2693
  y: snappedTop
2237
- }, true, hasNodes);
2694
+ }, true, hasNodes, this.flowType === 'message');
2238
2695
  }
2239
2696
  }
2240
2697
  handleEmptyFlowClick(event) {
@@ -2253,7 +2710,7 @@ export class Editor extends RapidElement {
2253
2710
  const menuWidth = 265;
2254
2711
  const menuX = rect.left + rect.width / 2 - menuWidth / 2;
2255
2712
  const menuY = rect.bottom + 8;
2256
- canvasMenu.show(menuX, menuY, { x: nodeLeft, y: nodeTop }, false);
2713
+ canvasMenu.show(menuX, menuY, { x: nodeLeft, y: nodeTop }, false, false, this.flowType === 'message');
2257
2714
  }
2258
2715
  }
2259
2716
  handleCanvasMenuSelection(event) {
@@ -2277,6 +2734,16 @@ export class Editor extends RapidElement {
2277
2734
  this.connectionSourceY = null;
2278
2735
  this.dragFromNodeId = null;
2279
2736
  }
2737
+ else if (selection.action === 'send_msg' ||
2738
+ selection.action === 'wait_for_response') {
2739
+ // Go directly to the node editor (skip node type selector)
2740
+ this.handleNodeTypeSelection(new CustomEvent(CustomEventType.Selection, {
2741
+ detail: {
2742
+ nodeType: selection.action,
2743
+ position: selection.position
2744
+ }
2745
+ }));
2746
+ }
2280
2747
  else {
2281
2748
  // Show node type selector
2282
2749
  const selector = this.querySelector('temba-node-type-selector');
@@ -3763,6 +4230,7 @@ export class Editor extends RapidElement {
3763
4230
  ? 'flow-start'
3764
4231
  : ''}"
3765
4232
  @mousedown=${this.handleMouseDown.bind(this)}
4233
+ @touchstart=${this.handleItemTouchStart.bind(this)}
3766
4234
  uuid=${node.uuid}
3767
4235
  data-node-uuid=${node.uuid}
3768
4236
  style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
@@ -3785,6 +4253,7 @@ export class Editor extends RapidElement {
3785
4253
  ? 'selected'
3786
4254
  : ''}"
3787
4255
  @mousedown=${this.handleMouseDown.bind(this)}
4256
+ @touchstart=${this.handleItemTouchStart.bind(this)}
3788
4257
  style="left:${position.left}px; top:${position.top}px;"
3789
4258
  uuid=${uuid}
3790
4259
  .data=${sticky}