@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
@@ -214,7 +214,7 @@ export class Editor extends RapidElement {
214
214
  private autoScrollAnimationId: number | null = null;
215
215
  private autoScrollDeltaX = 0;
216
216
  private autoScrollDeltaY = 0;
217
- private lastMouseEvent: MouseEvent | null = null;
217
+ private lastPointerPos: { clientX: number; clientY: number } | null = null;
218
218
 
219
219
  // Selection state
220
220
  @state()
@@ -226,6 +226,16 @@ export class Editor extends RapidElement {
226
226
  @state()
227
227
  private selectionBox: SelectionBox | null = null;
228
228
 
229
+ // Touch device state
230
+ private isTouchDevice = false;
231
+ private isTwoFingerPanning = false;
232
+ private twoFingerDidPan = false;
233
+ private twoFingerStartMidX = 0;
234
+ private twoFingerStartMidY = 0;
235
+ private twoFingerOnCanvas = false;
236
+ private lastPanX = 0;
237
+ private lastPanY = 0;
238
+
229
239
  @state()
230
240
  private targetId: string | null = null;
231
241
 
@@ -410,6 +420,10 @@ export class Editor extends RapidElement {
410
420
  private boundKeyDown = this.handleKeyDown.bind(this);
411
421
  private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
412
422
  private boundWheel = this.handleWheel.bind(this);
423
+ private boundTouchMove = this.handleTouchMove.bind(this);
424
+ private boundTouchEnd = this.handleTouchEnd.bind(this);
425
+ private boundTouchCancel = this.handleTouchCancel.bind(this);
426
+ private boundCanvasTouchStart = this.handleCanvasTouchStart.bind(this);
413
427
 
414
428
  static get styles() {
415
429
  return css`
@@ -426,6 +440,29 @@ export class Editor extends RapidElement {
426
440
  -webkit-font-smoothing: antialiased;
427
441
  }
428
442
 
443
+ /* On touch devices, disable native scroll-by-touch so canvas
444
+ drag draws a selection rectangle. Users scroll via scrollbars. */
445
+ #editor.touch-device {
446
+ touch-action: none;
447
+ }
448
+
449
+ #editor.touch-device::-webkit-scrollbar {
450
+ -webkit-appearance: none;
451
+ width: 12px;
452
+ height: 12px;
453
+ }
454
+
455
+ #editor.touch-device::-webkit-scrollbar-thumb {
456
+ background: rgba(0, 0, 0, 0.3);
457
+ border-radius: 6px;
458
+ border: 2px solid transparent;
459
+ background-clip: padding-box;
460
+ }
461
+
462
+ #editor.touch-device::-webkit-scrollbar-track {
463
+ background: rgba(0, 0, 0, 0.05);
464
+ }
465
+
429
466
  temba-floating-tab {
430
467
  --floating-tab-right: 15px;
431
468
  }
@@ -456,6 +493,7 @@ export class Editor extends RapidElement {
456
493
  #canvas > .draggable {
457
494
  position: absolute;
458
495
  z-index: 100;
496
+ touch-action: none;
459
497
  }
460
498
 
461
499
  #canvas > .dragging {
@@ -955,7 +993,7 @@ export class Editor extends RapidElement {
955
993
  top: 8px;
956
994
  right: 240px;
957
995
  padding: 6px 10px;
958
- z-index: 10000;
996
+ z-index: 4999;
959
997
  pointer-events: none;
960
998
  opacity: 0;
961
999
  transition: opacity 0.15s ease-in-out;
@@ -1106,6 +1144,12 @@ export class Editor extends RapidElement {
1106
1144
  super.firstUpdated(changes);
1107
1145
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
1108
1146
  this.setupGlobalEventListeners();
1147
+
1148
+ // Eagerly detect touch capability so hover-only controls are visible
1149
+ // from the start and scrollbar/touch-action CSS is applied immediately.
1150
+ if (navigator.maxTouchPoints > 0) {
1151
+ this.markTouchDevice();
1152
+ }
1109
1153
  this.updateZoomControlPositioning();
1110
1154
  if (changes.has('flow')) {
1111
1155
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
@@ -1188,8 +1232,10 @@ export class Editor extends RapidElement {
1188
1232
  x: snappedPosition.left,
1189
1233
  y: snappedPosition.top
1190
1234
  },
1191
- false
1192
- ); // Don't show sticky note option for connection drops
1235
+ false, // Don't show sticky note option for connection drops
1236
+ false,
1237
+ this.flowType === 'message'
1238
+ );
1193
1239
  }
1194
1240
  }
1195
1241
 
@@ -1506,10 +1552,14 @@ export class Editor extends RapidElement {
1506
1552
  document.removeEventListener('mouseup', this.boundMouseUp);
1507
1553
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
1508
1554
  document.removeEventListener('keydown', this.boundKeyDown);
1555
+ document.removeEventListener('touchmove', this.boundTouchMove);
1556
+ document.removeEventListener('touchend', this.boundTouchEnd);
1557
+ document.removeEventListener('touchcancel', this.boundTouchCancel);
1509
1558
 
1510
1559
  const canvas = this.querySelector('#canvas');
1511
1560
  if (canvas) {
1512
1561
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1562
+ canvas.removeEventListener('touchstart', this.boundCanvasTouchStart);
1513
1563
  }
1514
1564
 
1515
1565
  const editor = this.querySelector('#editor');
@@ -1527,10 +1577,26 @@ export class Editor extends RapidElement {
1527
1577
  document.addEventListener('mouseup', this.boundMouseUp);
1528
1578
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
1529
1579
  document.addEventListener('keydown', this.boundKeyDown);
1580
+ document.addEventListener('touchmove', this.boundTouchMove, {
1581
+ passive: false
1582
+ });
1583
+ document.addEventListener('touchend', this.boundTouchEnd);
1584
+ document.addEventListener('touchcancel', this.boundTouchCancel);
1585
+
1586
+ // Fallback: on first touch, mark as touch device in case
1587
+ // navigator.maxTouchPoints wasn't detected in firstUpdated.
1588
+ const markTouchOnce = () => {
1589
+ this.markTouchDevice();
1590
+ document.removeEventListener('touchstart', markTouchOnce);
1591
+ };
1592
+ document.addEventListener('touchstart', markTouchOnce);
1530
1593
 
1531
1594
  const canvas = this.querySelector('#canvas');
1532
1595
  if (canvas) {
1533
1596
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1597
+ canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
1598
+ passive: false
1599
+ });
1534
1600
  }
1535
1601
 
1536
1602
  const editor = this.querySelector('#editor');
@@ -1617,7 +1683,11 @@ export class Editor extends RapidElement {
1617
1683
  const element = event.currentTarget as HTMLElement;
1618
1684
  // Only start dragging if clicking on the element itself, not on exits or other interactive elements
1619
1685
  const target = event.target as HTMLElement;
1620
- if (target.classList.contains('exit') || target.closest('.exit')) {
1686
+ if (
1687
+ target.classList.contains('exit') ||
1688
+ target.closest('.exit') ||
1689
+ target.closest('.linked-name')
1690
+ ) {
1621
1691
  return;
1622
1692
  }
1623
1693
 
@@ -1652,6 +1722,71 @@ export class Editor extends RapidElement {
1652
1722
  event.stopPropagation();
1653
1723
  }
1654
1724
 
1725
+ /**
1726
+ * Mirror of handleMouseDown for touch devices.
1727
+ * Sets up the same drag state so handleTouchMove/End can drive the drag.
1728
+ */
1729
+ /* c8 ignore start -- touch-only handlers untestable in headless Chromium */
1730
+
1731
+ /**
1732
+ * Mark the editor as a touch device — adds classes to #canvas and
1733
+ * #editor so touch-specific CSS activates (visible controls,
1734
+ * always-on scrollbars, touch-action: none).
1735
+ */
1736
+ private markTouchDevice(): void {
1737
+ if (this.isTouchDevice) return;
1738
+ this.isTouchDevice = true;
1739
+ this.querySelector('#canvas')?.classList.add('touch-device');
1740
+ this.querySelector('#editor')?.classList.add('touch-device');
1741
+ }
1742
+
1743
+ private handleItemTouchStart(event: TouchEvent): void {
1744
+ this.markTouchDevice();
1745
+
1746
+ if (this.isReadOnly()) return;
1747
+ this.blurActiveContentEditable();
1748
+
1749
+ const touch = event.touches[0];
1750
+ if (!touch) return;
1751
+
1752
+ const element = event.currentTarget as HTMLElement;
1753
+ const target = event.target as HTMLElement;
1754
+ if (
1755
+ target.classList.contains('exit') ||
1756
+ target.closest('.exit') ||
1757
+ target.closest('.linked-name')
1758
+ ) {
1759
+ return;
1760
+ }
1761
+
1762
+ const uuid = element.getAttribute('uuid');
1763
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1764
+
1765
+ const position = this.getPosition(uuid, type);
1766
+ if (!position) return;
1767
+
1768
+ // Touch doesn't support Ctrl/Cmd selection — just clear
1769
+ if (!this.selectedItems.has(uuid)) {
1770
+ this.selectedItems.clear();
1771
+ }
1772
+
1773
+ this.isMouseDown = true;
1774
+ this.dragStartPos = { x: touch.clientX, y: touch.clientY };
1775
+ this.startPos = { left: position.left, top: position.top };
1776
+ this.currentDragItem = {
1777
+ uuid,
1778
+ position,
1779
+ element,
1780
+ type
1781
+ };
1782
+
1783
+ // Don't preventDefault here — allow the threshold check in touchmove
1784
+ // to decide whether this is a drag or a tap
1785
+ event.stopPropagation();
1786
+ }
1787
+
1788
+ /* c8 ignore stop */
1789
+
1655
1790
  private handleGlobalMouseDown(event: MouseEvent): void {
1656
1791
  if (isRightClick(event)) return;
1657
1792
 
@@ -2370,6 +2505,80 @@ export class Editor extends RapidElement {
2370
2505
  }
2371
2506
  }
2372
2507
 
2508
+ /* c8 ignore start -- touch-only handlers */
2509
+
2510
+ /**
2511
+ * Find the temba-flow-node element at the given viewport coordinates.
2512
+ * Uses elementFromPoint which works for both mouse and touch input.
2513
+ */
2514
+ private findTargetNodeAt(clientX: number, clientY: number): Element | null {
2515
+ const el = document.elementFromPoint(clientX, clientY);
2516
+ return el?.closest('temba-flow-node') ?? null;
2517
+ }
2518
+
2519
+ /**
2520
+ * Handle touchstart on the canvas element. Mirrors handleGlobalMouseDown
2521
+ * + handleCanvasMouseDown for touch: starts selection on empty canvas,
2522
+ * and detects double-tap to show the context menu.
2523
+ */
2524
+ private handleCanvasTouchStart(event: TouchEvent): void {
2525
+ this.markTouchDevice();
2526
+
2527
+ const touch = event.touches[0];
2528
+ if (!touch) return;
2529
+
2530
+ // Only handle touches directly on canvas/grid (not on nodes)
2531
+ const target = event.target as HTMLElement;
2532
+ if (target.closest('.draggable')) return;
2533
+ if (target.id !== 'canvas' && target.id !== 'grid') return;
2534
+
2535
+ // Two-finger touch on canvas — record start position and enter the
2536
+ // two-finger state immediately (even before any touchmove). If the
2537
+ // fingers lift without panning, we show the context menu (handleTouchEnd).
2538
+ if (event.touches.length >= 2) {
2539
+ // Cancel any single-finger selection that the first touch started
2540
+ this.canvasMouseDown = false;
2541
+ this.isSelecting = false;
2542
+ this.selectionBox = null;
2543
+
2544
+ this.isTwoFingerPanning = true;
2545
+ this.twoFingerOnCanvas = true;
2546
+ this.twoFingerDidPan = false;
2547
+ this.twoFingerStartMidX =
2548
+ (event.touches[0].clientX + event.touches[1].clientX) / 2;
2549
+ this.twoFingerStartMidY =
2550
+ (event.touches[0].clientY + event.touches[1].clientY) / 2;
2551
+ this.lastPanX = this.twoFingerStartMidX;
2552
+ this.lastPanY = this.twoFingerStartMidY;
2553
+ return;
2554
+ }
2555
+
2556
+ // Start selection box (mirrors handleCanvasMouseDown)
2557
+ if (this.isReadOnly()) return;
2558
+
2559
+ this.canvasMouseDown = true;
2560
+ this.dragStartPos = { x: touch.clientX, y: touch.clientY };
2561
+
2562
+ const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
2563
+ if (canvasRect) {
2564
+ this.selectedItems.clear();
2565
+
2566
+ const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
2567
+ const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
2568
+
2569
+ this.selectionBox = {
2570
+ startX: relativeX,
2571
+ startY: relativeY,
2572
+ endX: relativeX,
2573
+ endY: relativeY
2574
+ };
2575
+ }
2576
+
2577
+ event.preventDefault();
2578
+ }
2579
+
2580
+ /* c8 ignore stop */
2581
+
2373
2582
  private handleMouseMove(event: MouseEvent): void {
2374
2583
  // Handle selection box drawing
2375
2584
  if (this.canvasMouseDown && !this.isMouseDown) {
@@ -2455,7 +2664,7 @@ export class Editor extends RapidElement {
2455
2664
  // Handle item dragging
2456
2665
  if (!this.isMouseDown || !this.currentDragItem) return;
2457
2666
 
2458
- this.lastMouseEvent = event;
2667
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
2459
2668
 
2460
2669
  const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
2461
2670
  const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
@@ -2474,16 +2683,16 @@ export class Editor extends RapidElement {
2474
2683
  }
2475
2684
 
2476
2685
  private updateDragPositions(): void {
2477
- if (!this.currentDragItem || !this.lastMouseEvent) return;
2686
+ if (!this.currentDragItem || !this.lastPointerPos) return;
2478
2687
 
2479
2688
  // Convert screen + scroll delta to canvas delta
2480
2689
  const deltaX =
2481
- (this.lastMouseEvent.clientX -
2690
+ (this.lastPointerPos.clientX -
2482
2691
  this.dragStartPos.x +
2483
2692
  this.autoScrollDeltaX) /
2484
2693
  this.zoom;
2485
2694
  const deltaY =
2486
- (this.lastMouseEvent.clientY -
2695
+ (this.lastPointerPos.clientY -
2487
2696
  this.dragStartPos.y +
2488
2697
  this.autoScrollDeltaY) /
2489
2698
  this.zoom;
@@ -2518,14 +2727,14 @@ export class Editor extends RapidElement {
2518
2727
  if (!editor) return;
2519
2728
 
2520
2729
  const tick = () => {
2521
- if (!this.isDragging || !this.lastMouseEvent) {
2730
+ if (!this.isDragging || !this.lastPointerPos) {
2522
2731
  this.autoScrollAnimationId = null;
2523
2732
  return;
2524
2733
  }
2525
2734
 
2526
2735
  const editorRect = editor.getBoundingClientRect();
2527
- const mouseX = this.lastMouseEvent.clientX;
2528
- const mouseY = this.lastMouseEvent.clientY;
2736
+ const mouseX = this.lastPointerPos.clientX;
2737
+ const mouseY = this.lastPointerPos.clientY;
2529
2738
 
2530
2739
  let scrollDx = 0;
2531
2740
  let scrollDy = 0;
@@ -2699,9 +2908,332 @@ export class Editor extends RapidElement {
2699
2908
  this.canvasMouseDown = false;
2700
2909
  this.autoScrollDeltaX = 0;
2701
2910
  this.autoScrollDeltaY = 0;
2702
- this.lastMouseEvent = null;
2911
+ this.lastPointerPos = null;
2703
2912
  }
2704
2913
 
2914
+ /* c8 ignore start -- touch-only handlers */
2915
+
2916
+ /**
2917
+ * Handle touch move on the document — mirrors handleMouseMove for
2918
+ * both connection dragging and node/sticky dragging on touch devices.
2919
+ */
2920
+ private handleTouchMove(event: TouchEvent): void {
2921
+ // --- Two-finger panning ---
2922
+ if (event.touches.length >= 2) {
2923
+ event.preventDefault();
2924
+ const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
2925
+ const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
2926
+
2927
+ if (this.isTwoFingerPanning) {
2928
+ const dx = this.lastPanX - midX;
2929
+ const dy = this.lastPanY - midY;
2930
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
2931
+ this.twoFingerDidPan = true;
2932
+ }
2933
+ const editor = this.querySelector('#editor') as HTMLElement;
2934
+ if (editor) {
2935
+ editor.scrollBy(dx, dy);
2936
+ }
2937
+ }
2938
+
2939
+ // Cancel any in-progress single-finger actions
2940
+ this.canvasMouseDown = false;
2941
+ this.isSelecting = false;
2942
+ this.selectionBox = null;
2943
+
2944
+ this.isTwoFingerPanning = true;
2945
+ this.lastPanX = midX;
2946
+ this.lastPanY = midY;
2947
+ return;
2948
+ }
2949
+
2950
+ const touch = event.touches[0];
2951
+ if (!touch) return;
2952
+
2953
+ // --- Selection box drawing ---
2954
+ if (this.canvasMouseDown && !this.isMouseDown) {
2955
+ event.preventDefault();
2956
+ this.isSelecting = true;
2957
+
2958
+ const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
2959
+ if (canvasRect && this.selectionBox) {
2960
+ this.selectionBox = {
2961
+ ...this.selectionBox,
2962
+ endX: (touch.clientX - canvasRect.left) / this.zoom,
2963
+ endY: (touch.clientY - canvasRect.top) / this.zoom
2964
+ };
2965
+ this.updateSelectedItemsFromBox();
2966
+ }
2967
+
2968
+ this.requestUpdate();
2969
+ return;
2970
+ }
2971
+
2972
+ // --- Connection dragging ---
2973
+ if (this.plumber.connectionDragging) {
2974
+ event.preventDefault();
2975
+
2976
+ const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
2977
+
2978
+ // Clear previous target styles
2979
+ document.querySelectorAll('temba-flow-node').forEach((node) => {
2980
+ node.classList.remove(
2981
+ 'connection-target-valid',
2982
+ 'connection-target-invalid'
2983
+ );
2984
+ });
2985
+
2986
+ if (targetNode) {
2987
+ this.targetId = targetNode.getAttribute('uuid');
2988
+ this.isValidTarget = this.targetId !== this.dragFromNodeId;
2989
+
2990
+ if (this.isValidTarget) {
2991
+ targetNode.classList.add('connection-target-valid');
2992
+ } else {
2993
+ targetNode.classList.add('connection-target-invalid');
2994
+ }
2995
+
2996
+ this.connectionPlaceholder = null;
2997
+ } else {
2998
+ this.targetId = null;
2999
+ this.isValidTarget = true;
3000
+
3001
+ const canvas = this.querySelector('#canvas');
3002
+ if (canvas) {
3003
+ const canvasRect = canvas.getBoundingClientRect();
3004
+ const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
3005
+ const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
3006
+
3007
+ const placeholderWidth = 200;
3008
+ const placeholderHeight = 64;
3009
+ const arrowLength = ARROW_LENGTH;
3010
+ const cursorGap = CURSOR_GAP;
3011
+
3012
+ const dragUp =
3013
+ this.connectionSourceY != null
3014
+ ? relativeY < this.connectionSourceY
3015
+ : false;
3016
+
3017
+ let top: number;
3018
+ if (dragUp) {
3019
+ top = relativeY + cursorGap - placeholderHeight;
3020
+ } else {
3021
+ top = relativeY - cursorGap + arrowLength;
3022
+ }
3023
+
3024
+ this.connectionPlaceholder = {
3025
+ position: {
3026
+ left: relativeX - placeholderWidth / 2,
3027
+ top
3028
+ },
3029
+ visible: true,
3030
+ dragUp
3031
+ };
3032
+ }
3033
+ }
3034
+
3035
+ this.requestUpdate();
3036
+ return;
3037
+ }
3038
+
3039
+ // --- Node/sticky dragging ---
3040
+ if (!this.isMouseDown || !this.currentDragItem) return;
3041
+
3042
+ this.lastPointerPos = { clientX: touch.clientX, clientY: touch.clientY };
3043
+
3044
+ const deltaX = touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
3045
+ const deltaY = touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
3046
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
3047
+
3048
+ if (!this.isDragging && distance > DRAG_THRESHOLD) {
3049
+ this.isDragging = true;
3050
+ this.startAutoScroll();
3051
+ }
3052
+
3053
+ // Only prevent default scrolling once we're actually dragging.
3054
+ // Before the threshold, allow the browser to fire synthetic click
3055
+ // events for taps on buttons (remove, add-action, etc.).
3056
+ if (this.isDragging) {
3057
+ event.preventDefault();
3058
+ this.updateDragPositions();
3059
+ }
3060
+ }
3061
+
3062
+ /**
3063
+ * Handle touch end on the document — mirrors handleMouseUp for
3064
+ * both connection dragging and node/sticky dragging on touch devices.
3065
+ */
3066
+ private handleTouchEnd(event: TouchEvent): void {
3067
+ // --- Two-finger gesture end ---
3068
+ if (this.isTwoFingerPanning) {
3069
+ if (event.touches.length === 0) {
3070
+ const didPan = this.twoFingerDidPan;
3071
+ const onCanvas = this.twoFingerOnCanvas;
3072
+ const midX = this.twoFingerStartMidX;
3073
+ const midY = this.twoFingerStartMidY;
3074
+
3075
+ // Reset state
3076
+ this.isTwoFingerPanning = false;
3077
+ this.twoFingerOnCanvas = false;
3078
+ this.twoFingerDidPan = false;
3079
+
3080
+ // Two-finger tap (no pan) on canvas → show context menu
3081
+ if (!didPan && onCanvas) {
3082
+ this.showContextMenuAt(midX, midY);
3083
+ }
3084
+ }
3085
+ return;
3086
+ }
3087
+
3088
+ const touch = event.changedTouches[0];
3089
+
3090
+ // --- Selection box completion ---
3091
+ if (this.canvasMouseDown && this.isSelecting) {
3092
+ this.isSelecting = false;
3093
+ this.selectionBox = null;
3094
+ this.canvasMouseDown = false;
3095
+ this.requestUpdate();
3096
+ return;
3097
+ }
3098
+
3099
+ // --- Canvas tap (no drag) — clear selection ---
3100
+ if (this.canvasMouseDown && !this.isSelecting) {
3101
+ this.canvasMouseDown = false;
3102
+ return;
3103
+ }
3104
+
3105
+ // --- Connection dragging ---
3106
+ if (this.plumber.connectionDragging) {
3107
+ if (touch) {
3108
+ const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
3109
+ if (targetNode) {
3110
+ this.targetId = targetNode.getAttribute('uuid');
3111
+ this.isValidTarget = this.targetId !== this.dragFromNodeId;
3112
+ }
3113
+ }
3114
+ return;
3115
+ }
3116
+
3117
+ // --- Node/sticky dragging ---
3118
+ if (!this.isMouseDown || !this.currentDragItem) return;
3119
+
3120
+ this.stopAutoScroll();
3121
+
3122
+ if (this.isDragging && touch) {
3123
+ const deltaX =
3124
+ (touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
3125
+ this.zoom;
3126
+ const deltaY =
3127
+ (touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
3128
+ this.zoom;
3129
+
3130
+ const itemsToMove =
3131
+ this.selectedItems.has(this.currentDragItem.uuid) &&
3132
+ this.selectedItems.size > 1
3133
+ ? Array.from(this.selectedItems)
3134
+ : [this.currentDragItem.uuid];
3135
+
3136
+ const newPositions: { [uuid: string]: FlowPosition } = {};
3137
+
3138
+ itemsToMove.forEach((uuid) => {
3139
+ const type = this.definition.nodes.find((node) => node.uuid === uuid)
3140
+ ? 'node'
3141
+ : 'sticky';
3142
+ const position = this.getPosition(uuid, type);
3143
+
3144
+ if (position) {
3145
+ const newLeft = position.left + deltaX;
3146
+ const newTop = position.top + deltaY;
3147
+ const snappedLeft = snapToGrid(newLeft);
3148
+ const snappedTop = snapToGrid(newTop);
3149
+
3150
+ newPositions[uuid] = { left: snappedLeft, top: snappedTop };
3151
+
3152
+ const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3153
+ if (element) {
3154
+ element.classList.remove('dragging');
3155
+ element.style.left = `${snappedLeft}px`;
3156
+ element.style.top = `${snappedTop}px`;
3157
+ }
3158
+ }
3159
+ });
3160
+
3161
+ if (Object.keys(newPositions).length > 0) {
3162
+ getStore().getState().updateCanvasPositions(newPositions);
3163
+
3164
+ const nodeUuids = itemsToMove.filter((uuid) =>
3165
+ this.definition.nodes.find((node) => node.uuid === uuid)
3166
+ );
3167
+
3168
+ if (nodeUuids.length > 0) {
3169
+ setTimeout(() => {
3170
+ this.checkCollisionsAndReflow(nodeUuids);
3171
+ }, 0);
3172
+ } else {
3173
+ setTimeout(() => {
3174
+ this.plumber.repaintEverything();
3175
+ }, 0);
3176
+ }
3177
+ }
3178
+
3179
+ this.selectedItems.clear();
3180
+ }
3181
+
3182
+ // Reset all drag state
3183
+ this.isDragging = false;
3184
+ this.isMouseDown = false;
3185
+ this.currentDragItem = null;
3186
+ this.canvasMouseDown = false;
3187
+ this.autoScrollDeltaX = 0;
3188
+ this.autoScrollDeltaY = 0;
3189
+ this.lastPointerPos = null;
3190
+ }
3191
+
3192
+ /**
3193
+ * Handle touchcancel — reset all touch-related state so the editor
3194
+ * doesn't get stuck in a partial drag/selection mode.
3195
+ */
3196
+ private handleTouchCancel(): void {
3197
+ this.isTwoFingerPanning = false;
3198
+ this.isSelecting = false;
3199
+ this.selectionBox = null;
3200
+ this.canvasMouseDown = false;
3201
+
3202
+ if (this.isDragging && this.currentDragItem) {
3203
+ // Remove dragging class from all moved items
3204
+ const itemsToReset =
3205
+ this.selectedItems.has(this.currentDragItem.uuid) &&
3206
+ this.selectedItems.size > 1
3207
+ ? Array.from(this.selectedItems)
3208
+ : [this.currentDragItem.uuid];
3209
+ itemsToReset.forEach((uuid) => {
3210
+ const el = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3211
+ if (el) el.classList.remove('dragging');
3212
+ });
3213
+ }
3214
+
3215
+ this.stopAutoScroll();
3216
+ this.isDragging = false;
3217
+ this.isMouseDown = false;
3218
+ this.currentDragItem = null;
3219
+ this.autoScrollDeltaX = 0;
3220
+ this.autoScrollDeltaY = 0;
3221
+ this.lastPointerPos = null;
3222
+
3223
+ // Clear connection drag visual state
3224
+ document.querySelectorAll('temba-flow-node').forEach((node) => {
3225
+ node.classList.remove(
3226
+ 'connection-target-valid',
3227
+ 'connection-target-invalid'
3228
+ );
3229
+ });
3230
+ this.connectionPlaceholder = null;
3231
+
3232
+ this.requestUpdate();
3233
+ }
3234
+
3235
+ /* c8 ignore stop */
3236
+
2705
3237
  private updateCanvasSize(): void {
2706
3238
  if (!this.definition) return;
2707
3239
 
@@ -2777,33 +3309,39 @@ export class Editor extends RapidElement {
2777
3309
  event.preventDefault();
2778
3310
  event.stopPropagation();
2779
3311
 
2780
- // Get canvas position
3312
+ this.showContextMenuAt(event.clientX, event.clientY);
3313
+ }
3314
+
3315
+ /**
3316
+ * Show the canvas context menu at the given viewport coordinates.
3317
+ * Shared by right-click (mouse) and double-tap (touch).
3318
+ */
3319
+ private showContextMenuAt(clientX: number, clientY: number): void {
3320
+ if (this.isReadOnly()) return;
3321
+
2781
3322
  const canvas = this.querySelector('#canvas');
2782
- if (!canvas) {
2783
- return;
2784
- }
3323
+ if (!canvas) return;
2785
3324
 
2786
3325
  const canvasRect = canvas.getBoundingClientRect();
2787
- const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
2788
- const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
3326
+ const relativeX = (clientX - canvasRect.left) / this.zoom - 10;
3327
+ const relativeY = (clientY - canvasRect.top) / this.zoom - 10;
2789
3328
 
2790
- // Snap position to grid
2791
3329
  const snappedLeft = snapToGrid(relativeX);
2792
3330
  const snappedTop = snapToGrid(relativeY);
2793
3331
 
2794
- // Show the canvas menu at the mouse position (use viewport coordinates)
2795
3332
  const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
2796
3333
  if (canvasMenu) {
2797
3334
  const hasNodes = this.definition && this.definition.nodes.length > 0;
2798
3335
  canvasMenu.show(
2799
- event.clientX,
2800
- event.clientY,
3336
+ clientX,
3337
+ clientY,
2801
3338
  {
2802
3339
  x: snappedLeft,
2803
3340
  y: snappedTop
2804
3341
  },
2805
3342
  true,
2806
- hasNodes
3343
+ hasNodes,
3344
+ this.flowType === 'message'
2807
3345
  );
2808
3346
  }
2809
3347
  }
@@ -2826,7 +3364,14 @@ export class Editor extends RapidElement {
2826
3364
  const menuWidth = 265;
2827
3365
  const menuX = rect.left + rect.width / 2 - menuWidth / 2;
2828
3366
  const menuY = rect.bottom + 8;
2829
- canvasMenu.show(menuX, menuY, { x: nodeLeft, y: nodeTop }, false);
3367
+ canvasMenu.show(
3368
+ menuX,
3369
+ menuY,
3370
+ { x: nodeLeft, y: nodeTop },
3371
+ false,
3372
+ false,
3373
+ this.flowType === 'message'
3374
+ );
2830
3375
  }
2831
3376
  }
2832
3377
 
@@ -2852,6 +3397,19 @@ export class Editor extends RapidElement {
2852
3397
  this.connectionSourceX = null;
2853
3398
  this.connectionSourceY = null;
2854
3399
  this.dragFromNodeId = null;
3400
+ } else if (
3401
+ selection.action === 'send_msg' ||
3402
+ selection.action === 'wait_for_response'
3403
+ ) {
3404
+ // Go directly to the node editor (skip node type selector)
3405
+ this.handleNodeTypeSelection(
3406
+ new CustomEvent(CustomEventType.Selection, {
3407
+ detail: {
3408
+ nodeType: selection.action,
3409
+ position: selection.position
3410
+ } as NodeTypeSelection
3411
+ })
3412
+ );
2855
3413
  } else {
2856
3414
  // Show node type selector
2857
3415
  const selector = this.querySelector(
@@ -4716,6 +5274,7 @@ export class Editor extends RapidElement {
4716
5274
  ? 'flow-start'
4717
5275
  : ''}"
4718
5276
  @mousedown=${this.handleMouseDown.bind(this)}
5277
+ @touchstart=${this.handleItemTouchStart.bind(this)}
4719
5278
  uuid=${node.uuid}
4720
5279
  data-node-uuid=${node.uuid}
4721
5280
  style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
@@ -4742,6 +5301,7 @@ export class Editor extends RapidElement {
4742
5301
  ? 'selected'
4743
5302
  : ''}"
4744
5303
  @mousedown=${this.handleMouseDown.bind(this)}
5304
+ @touchstart=${this.handleItemTouchStart.bind(this)}
4745
5305
  style="left:${position.left}px; top:${position.top}px;"
4746
5306
  uuid=${uuid}
4747
5307
  .data=${sticky}