@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
@@ -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 {
@@ -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}`);
@@ -1508,10 +1552,14 @@ export class Editor extends RapidElement {
1508
1552
  document.removeEventListener('mouseup', this.boundMouseUp);
1509
1553
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
1510
1554
  document.removeEventListener('keydown', this.boundKeyDown);
1555
+ document.removeEventListener('touchmove', this.boundTouchMove);
1556
+ document.removeEventListener('touchend', this.boundTouchEnd);
1557
+ document.removeEventListener('touchcancel', this.boundTouchCancel);
1511
1558
 
1512
1559
  const canvas = this.querySelector('#canvas');
1513
1560
  if (canvas) {
1514
1561
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1562
+ canvas.removeEventListener('touchstart', this.boundCanvasTouchStart);
1515
1563
  }
1516
1564
 
1517
1565
  const editor = this.querySelector('#editor');
@@ -1529,10 +1577,26 @@ export class Editor extends RapidElement {
1529
1577
  document.addEventListener('mouseup', this.boundMouseUp);
1530
1578
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
1531
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);
1532
1593
 
1533
1594
  const canvas = this.querySelector('#canvas');
1534
1595
  if (canvas) {
1535
1596
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1597
+ canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
1598
+ passive: false
1599
+ });
1536
1600
  }
1537
1601
 
1538
1602
  const editor = this.querySelector('#editor');
@@ -1658,6 +1722,71 @@ export class Editor extends RapidElement {
1658
1722
  event.stopPropagation();
1659
1723
  }
1660
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
+
1661
1790
  private handleGlobalMouseDown(event: MouseEvent): void {
1662
1791
  if (isRightClick(event)) return;
1663
1792
 
@@ -2376,6 +2505,80 @@ export class Editor extends RapidElement {
2376
2505
  }
2377
2506
  }
2378
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
+
2379
2582
  private handleMouseMove(event: MouseEvent): void {
2380
2583
  // Handle selection box drawing
2381
2584
  if (this.canvasMouseDown && !this.isMouseDown) {
@@ -2461,7 +2664,7 @@ export class Editor extends RapidElement {
2461
2664
  // Handle item dragging
2462
2665
  if (!this.isMouseDown || !this.currentDragItem) return;
2463
2666
 
2464
- this.lastMouseEvent = event;
2667
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
2465
2668
 
2466
2669
  const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
2467
2670
  const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
@@ -2480,16 +2683,16 @@ export class Editor extends RapidElement {
2480
2683
  }
2481
2684
 
2482
2685
  private updateDragPositions(): void {
2483
- if (!this.currentDragItem || !this.lastMouseEvent) return;
2686
+ if (!this.currentDragItem || !this.lastPointerPos) return;
2484
2687
 
2485
2688
  // Convert screen + scroll delta to canvas delta
2486
2689
  const deltaX =
2487
- (this.lastMouseEvent.clientX -
2690
+ (this.lastPointerPos.clientX -
2488
2691
  this.dragStartPos.x +
2489
2692
  this.autoScrollDeltaX) /
2490
2693
  this.zoom;
2491
2694
  const deltaY =
2492
- (this.lastMouseEvent.clientY -
2695
+ (this.lastPointerPos.clientY -
2493
2696
  this.dragStartPos.y +
2494
2697
  this.autoScrollDeltaY) /
2495
2698
  this.zoom;
@@ -2524,14 +2727,14 @@ export class Editor extends RapidElement {
2524
2727
  if (!editor) return;
2525
2728
 
2526
2729
  const tick = () => {
2527
- if (!this.isDragging || !this.lastMouseEvent) {
2730
+ if (!this.isDragging || !this.lastPointerPos) {
2528
2731
  this.autoScrollAnimationId = null;
2529
2732
  return;
2530
2733
  }
2531
2734
 
2532
2735
  const editorRect = editor.getBoundingClientRect();
2533
- const mouseX = this.lastMouseEvent.clientX;
2534
- const mouseY = this.lastMouseEvent.clientY;
2736
+ const mouseX = this.lastPointerPos.clientX;
2737
+ const mouseY = this.lastPointerPos.clientY;
2535
2738
 
2536
2739
  let scrollDx = 0;
2537
2740
  let scrollDy = 0;
@@ -2705,9 +2908,332 @@ export class Editor extends RapidElement {
2705
2908
  this.canvasMouseDown = false;
2706
2909
  this.autoScrollDeltaX = 0;
2707
2910
  this.autoScrollDeltaY = 0;
2708
- this.lastMouseEvent = null;
2911
+ this.lastPointerPos = null;
2709
2912
  }
2710
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
+
2711
3237
  private updateCanvasSize(): void {
2712
3238
  if (!this.definition) return;
2713
3239
 
@@ -2783,27 +3309,32 @@ export class Editor extends RapidElement {
2783
3309
  event.preventDefault();
2784
3310
  event.stopPropagation();
2785
3311
 
2786
- // 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
+
2787
3322
  const canvas = this.querySelector('#canvas');
2788
- if (!canvas) {
2789
- return;
2790
- }
3323
+ if (!canvas) return;
2791
3324
 
2792
3325
  const canvasRect = canvas.getBoundingClientRect();
2793
- const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
2794
- 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;
2795
3328
 
2796
- // Snap position to grid
2797
3329
  const snappedLeft = snapToGrid(relativeX);
2798
3330
  const snappedTop = snapToGrid(relativeY);
2799
3331
 
2800
- // Show the canvas menu at the mouse position (use viewport coordinates)
2801
3332
  const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
2802
3333
  if (canvasMenu) {
2803
3334
  const hasNodes = this.definition && this.definition.nodes.length > 0;
2804
3335
  canvasMenu.show(
2805
- event.clientX,
2806
- event.clientY,
3336
+ clientX,
3337
+ clientY,
2807
3338
  {
2808
3339
  x: snappedLeft,
2809
3340
  y: snappedTop
@@ -4743,6 +5274,7 @@ export class Editor extends RapidElement {
4743
5274
  ? 'flow-start'
4744
5275
  : ''}"
4745
5276
  @mousedown=${this.handleMouseDown.bind(this)}
5277
+ @touchstart=${this.handleItemTouchStart.bind(this)}
4746
5278
  uuid=${node.uuid}
4747
5279
  data-node-uuid=${node.uuid}
4748
5280
  style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
@@ -4769,6 +5301,7 @@ export class Editor extends RapidElement {
4769
5301
  ? 'selected'
4770
5302
  : ''}"
4771
5303
  @mousedown=${this.handleMouseDown.bind(this)}
5304
+ @touchstart=${this.handleItemTouchStart.bind(this)}
4772
5305
  style="left:${position.left}px; top:${position.top}px;"
4773
5306
  uuid=${uuid}
4774
5307
  .data=${sticky}