@nyaruka/temba-components 0.142.2 → 0.142.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/temba-components.js +266 -192
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +8 -3
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +158 -9
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +473 -17
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +8 -8
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +89 -27
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/StickyNote.js +63 -3
- package/out-tsc/src/flow/StickyNote.js.map +1 -1
- package/out-tsc/src/flow/actions/send_msg.js +11 -8
- package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
- package/out-tsc/src/flow/nodes/split_by_subflow.js +3 -1
- package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
- package/out-tsc/src/flow/utils.js +1 -3
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/list/SortableList.js +104 -43
- package/out-tsc/src/list/SortableList.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +5 -1
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
- package/src/flow/CanvasMenu.ts +12 -4
- package/src/flow/CanvasNode.ts +185 -9
- package/src/flow/Editor.ts +552 -19
- package/src/flow/NodeEditor.ts +12 -12
- package/src/flow/Plumber.ts +101 -36
- package/src/flow/StickyNote.ts +76 -4
- package/src/flow/actions/send_msg.ts +11 -8
- package/src/flow/nodes/split_by_subflow.ts +3 -1
- package/src/flow/utils.ts +1 -3
- package/src/list/SortableList.ts +117 -47
- package/src/simulator/Simulator.ts +5 -1
package/src/flow/Editor.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
2686
|
+
if (!this.currentDragItem || !this.lastPointerPos) return;
|
|
2484
2687
|
|
|
2485
2688
|
// Convert screen + scroll delta to canvas delta
|
|
2486
2689
|
const deltaX =
|
|
2487
|
-
(this.
|
|
2690
|
+
(this.lastPointerPos.clientX -
|
|
2488
2691
|
this.dragStartPos.x +
|
|
2489
2692
|
this.autoScrollDeltaX) /
|
|
2490
2693
|
this.zoom;
|
|
2491
2694
|
const deltaY =
|
|
2492
|
-
(this.
|
|
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.
|
|
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.
|
|
2534
|
-
const mouseY = this.
|
|
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.
|
|
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
|
-
|
|
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 = (
|
|
2794
|
-
const relativeY = (
|
|
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
|
-
|
|
2806
|
-
|
|
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}
|