@nyaruka/temba-components 0.156.6 → 0.156.8

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.
@@ -0,0 +1,1239 @@
1
+ import { html, TemplateResult } from 'lit-html';
2
+ import { FlowPosition } from '../store/flow-definition';
3
+ import { getStore } from '../store/Store';
4
+ import { isRightClick, snapToGrid } from './utils';
5
+ import { ARROW_LENGTH, CURSOR_GAP } from './Plumber';
6
+ import type { Editor, DraggableItem } from './Editor';
7
+
8
+ const DRAG_THRESHOLD = 5;
9
+ const AUTO_SCROLL_EDGE_ZONE = 150;
10
+ const AUTO_SCROLL_EDGE_ZONE_TOUCH = 40;
11
+ const AUTO_SCROLL_MAX_SPEED = 15;
12
+ const AUTO_SCROLL_BEYOND_MULTIPLIER = 5;
13
+
14
+ export class DragManager {
15
+ // Drag state
16
+ private isMouseDown = false;
17
+ private shiftDragCopy = false;
18
+ private currentDragIsCopy = false;
19
+ private dragStartPos = { x: 0, y: 0 };
20
+
21
+ // Mid-drag shift toggle: remember originals so we can switch between move/copy
22
+ private originalDragItem: DraggableItem | null = null;
23
+ private originalSelectedItems: Set<string> | null = null;
24
+
25
+ // Drag hint tooltip
26
+ private dragHintTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ // Auto-scroll state
29
+ private autoScrollAnimationId: number | null = null;
30
+ private autoScrollDeltaX = 0;
31
+ private autoScrollDeltaY = 0;
32
+ private lastPointerPos: { clientX: number; clientY: number } | null = null;
33
+ private activeDragIsTouch = false;
34
+
35
+ // Touch device state
36
+ private isTouchDevice = false;
37
+ private isTwoFingerPanning = false;
38
+ private twoFingerDidPan = false;
39
+ private twoFingerStartMidX = 0;
40
+ private twoFingerStartMidY = 0;
41
+ private twoFingerOnCanvas = false;
42
+ private lastPanX = 0;
43
+ private lastPanY = 0;
44
+
45
+ private canvasMouseDown = false;
46
+
47
+ // Bound event handlers
48
+ private boundMouseMove: (e: MouseEvent) => void;
49
+ private boundMouseUp: (e: MouseEvent) => void;
50
+ private boundGlobalMouseDown: (e: MouseEvent) => void;
51
+ private boundKeyDown: (e: KeyboardEvent) => void;
52
+ private boundKeyUp: (e: KeyboardEvent) => void;
53
+ private boundWindowBlur: () => void;
54
+ private boundTouchMove: (e: TouchEvent) => void;
55
+ private boundTouchEnd: (e: TouchEvent) => void;
56
+ private boundTouchCancel: () => void;
57
+ private boundCanvasTouchStart: (e: TouchEvent) => void;
58
+
59
+ constructor(private editor: Editor) {
60
+ this.boundMouseMove = this.handleMouseMove.bind(this);
61
+ this.boundMouseUp = this.handleMouseUp.bind(this);
62
+ this.boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
63
+ this.boundKeyDown = this.handleKeyDown.bind(this);
64
+ this.boundKeyUp = this.handleKeyUp.bind(this);
65
+ this.boundWindowBlur = this.handleWindowBlur.bind(this);
66
+ this.boundTouchMove = this.handleTouchMove.bind(this);
67
+ this.boundTouchEnd = this.handleTouchEnd.bind(this);
68
+ this.boundTouchCancel = this.handleTouchCancel.bind(this);
69
+ this.boundCanvasTouchStart = this.handleCanvasTouchStart.bind(this);
70
+ }
71
+
72
+ public setupListeners(): void {
73
+ document.addEventListener('mousemove', this.boundMouseMove);
74
+ document.addEventListener('mouseup', this.boundMouseUp);
75
+ document.addEventListener('mousedown', this.boundGlobalMouseDown);
76
+ document.addEventListener('keydown', this.boundKeyDown);
77
+ document.addEventListener('keyup', this.boundKeyUp);
78
+ window.addEventListener('blur', this.boundWindowBlur);
79
+ document.addEventListener('touchmove', this.boundTouchMove, {
80
+ passive: false
81
+ });
82
+ document.addEventListener('touchend', this.boundTouchEnd);
83
+ document.addEventListener('touchcancel', this.boundTouchCancel);
84
+ const canvas = this.editor.querySelector('#canvas');
85
+ canvas?.addEventListener(
86
+ 'touchstart',
87
+ this.boundCanvasTouchStart as EventListener,
88
+ { passive: false }
89
+ );
90
+ }
91
+
92
+ public teardownListeners(): void {
93
+ document.removeEventListener('mousemove', this.boundMouseMove);
94
+ document.removeEventListener('mouseup', this.boundMouseUp);
95
+ document.removeEventListener('mousedown', this.boundGlobalMouseDown);
96
+ document.removeEventListener('keydown', this.boundKeyDown);
97
+ document.removeEventListener('keyup', this.boundKeyUp);
98
+ window.removeEventListener('blur', this.boundWindowBlur);
99
+ document.removeEventListener('touchmove', this.boundTouchMove);
100
+ document.removeEventListener('touchend', this.boundTouchEnd);
101
+ document.removeEventListener('touchcancel', this.boundTouchCancel);
102
+ const canvas = this.editor.querySelector('#canvas');
103
+ canvas?.removeEventListener(
104
+ 'touchstart',
105
+ this.boundCanvasTouchStart as EventListener
106
+ );
107
+ this.stopAutoScroll();
108
+ this.hideDragHint();
109
+ }
110
+
111
+ private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition {
112
+ if (type === 'node') {
113
+ return this.editor.definition._ui.nodes[uuid]?.position;
114
+ } else {
115
+ return this.editor.definition._ui.stickies?.[uuid]?.position;
116
+ }
117
+ }
118
+
119
+ public handleMouseDown(event: MouseEvent): void {
120
+ if (isRightClick(event)) return;
121
+
122
+ if (this.editor.isReadOnly()) return;
123
+ this.blurActiveContentEditable();
124
+
125
+ const element = event.currentTarget as HTMLElement;
126
+ const target = event.target as HTMLElement;
127
+ if (
128
+ target.classList.contains('exit') ||
129
+ target.closest('.exit') ||
130
+ target.closest('.linked-name')
131
+ ) {
132
+ return;
133
+ }
134
+
135
+ const uuid = element.getAttribute('uuid');
136
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
137
+
138
+ const position = this.getPosition(uuid, type);
139
+ if (!position) return;
140
+
141
+ if (!this.editor.selectedItems.has(uuid) && !event.ctrlKey && !event.metaKey) {
142
+ this.editor.selectedItems.clear();
143
+ } else if (!this.editor.selectedItems.has(uuid)) {
144
+ this.editor.selectedItems.add(uuid);
145
+ }
146
+
147
+ this.isMouseDown = true;
148
+ this.activeDragIsTouch = false;
149
+ this.shiftDragCopy = event.shiftKey;
150
+ this.dragStartPos = { x: event.clientX, y: event.clientY };
151
+ this.editor.currentDragItem = {
152
+ uuid,
153
+ position,
154
+ element,
155
+ type
156
+ };
157
+
158
+ event.preventDefault();
159
+ event.stopPropagation();
160
+ }
161
+
162
+ /* c8 ignore start -- touch-only handlers untestable in headless Chromium */
163
+
164
+ private markTouchDevice(): void {
165
+ if (this.isTouchDevice) return;
166
+ this.isTouchDevice = true;
167
+ this.editor.querySelector('#canvas')?.classList.add('touch-device');
168
+ this.editor.querySelector('#editor')?.classList.add('touch-device');
169
+ }
170
+
171
+ public handleItemTouchStart(event: TouchEvent): void {
172
+ this.markTouchDevice();
173
+
174
+ if (this.editor.isReadOnly()) return;
175
+ this.blurActiveContentEditable();
176
+
177
+ const touch = event.touches[0];
178
+ if (!touch) return;
179
+
180
+ const element = event.currentTarget as HTMLElement;
181
+ const target = event.target as HTMLElement;
182
+ if (
183
+ target.classList.contains('exit') ||
184
+ target.closest('.exit') ||
185
+ target.closest('.linked-name')
186
+ ) {
187
+ return;
188
+ }
189
+
190
+ const uuid = element.getAttribute('uuid');
191
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
192
+
193
+ const position = this.getPosition(uuid, type);
194
+ if (!position) return;
195
+
196
+ if (!this.editor.selectedItems.has(uuid)) {
197
+ this.editor.selectedItems.clear();
198
+ }
199
+
200
+ this.isMouseDown = true;
201
+ this.activeDragIsTouch = true;
202
+ this.dragStartPos = { x: touch.clientX, y: touch.clientY };
203
+ this.editor.currentDragItem = {
204
+ uuid,
205
+ position,
206
+ element,
207
+ type
208
+ };
209
+
210
+ // Don't preventDefault here — allow the threshold check in touchmove
211
+ // to decide whether this is a drag or a tap
212
+ event.stopPropagation();
213
+ }
214
+
215
+ private handleGlobalMouseDown(event: MouseEvent): void {
216
+ if (isRightClick(event)) return;
217
+
218
+ const canvasRect = this.editor.querySelector('#grid')?.getBoundingClientRect();
219
+ if (!canvasRect) return;
220
+
221
+ const isWithinCanvas =
222
+ event.clientX >= canvasRect.left &&
223
+ event.clientX <= canvasRect.right &&
224
+ event.clientY >= canvasRect.top &&
225
+ event.clientY <= canvasRect.bottom;
226
+
227
+ if (!isWithinCanvas) return;
228
+
229
+ const target = event.target as HTMLElement;
230
+ const clickedOnDraggable = target.closest('.draggable');
231
+
232
+ if (clickedOnDraggable) {
233
+ return;
234
+ }
235
+
236
+ this.handleCanvasMouseDown(event);
237
+ }
238
+
239
+ public blurActiveContentEditable(): void {
240
+ let active: Element | null = document.activeElement;
241
+ while (active?.shadowRoot?.activeElement) {
242
+ active = active.shadowRoot.activeElement;
243
+ }
244
+ if (
245
+ active instanceof HTMLElement &&
246
+ active.getAttribute('contenteditable') === 'true'
247
+ ) {
248
+ active.blur();
249
+ }
250
+ }
251
+
252
+ private handleCanvasMouseDown(event: MouseEvent): void {
253
+ if (this.editor.isReadOnly()) return;
254
+ this.blurActiveContentEditable();
255
+
256
+ const target = event.target as HTMLElement;
257
+ if (target.id === 'canvas' || target.id === 'grid') {
258
+ this.canvasMouseDown = true;
259
+ this.dragStartPos = { x: event.clientX, y: event.clientY };
260
+
261
+ const canvasRect = this.editor.querySelector('#canvas')?.getBoundingClientRect();
262
+ if (canvasRect) {
263
+ this.editor.selectedItems.clear();
264
+
265
+ const relativeX = (event.clientX - canvasRect.left) / this.editor.zoom;
266
+ const relativeY = (event.clientY - canvasRect.top) / this.editor.zoom;
267
+
268
+ this.editor.selectionBox = {
269
+ startX: relativeX,
270
+ startY: relativeY,
271
+ endX: relativeX,
272
+ endY: relativeY
273
+ };
274
+ }
275
+
276
+ event.preventDefault();
277
+ }
278
+ }
279
+
280
+ private showDragHint(): void {
281
+ if (this.editor.isReadOnly()) return;
282
+ const hint = this.editor.querySelector('#drag-hint') as HTMLElement;
283
+ if (!hint) return;
284
+ this.dragHintTimer = setTimeout(() => {
285
+ hint.classList.add('visible');
286
+ this.dragHintTimer = null;
287
+ }, 600);
288
+ }
289
+
290
+ private hideDragHint(): void {
291
+ if (this.dragHintTimer) {
292
+ clearTimeout(this.dragHintTimer);
293
+ this.dragHintTimer = null;
294
+ }
295
+ const hint = this.editor.querySelector('#drag-hint') as HTMLElement;
296
+ if (hint) {
297
+ hint.classList.remove('visible');
298
+ }
299
+ }
300
+
301
+ private handleKeyDown(event: KeyboardEvent): void {
302
+ if (event.key === 'Shift') {
303
+ this.editor.querySelector('#canvas')?.classList.add('shift-held');
304
+
305
+ // Toggle to copy mode mid-drag (nodes)
306
+ if (this.editor.isDragging && !this.currentDragIsCopy) {
307
+ this.hideDragHint();
308
+ this.performShiftDragCopy();
309
+ requestAnimationFrame(() => {
310
+ this.markCopyElements();
311
+ this.updateDragPositions();
312
+ });
313
+ }
314
+ }
315
+
316
+ // Forward to editor for non-drag keyboard handling
317
+ this.editor.handleKeyDown(event);
318
+ }
319
+
320
+ private handleKeyUp(event: KeyboardEvent): void {
321
+ if (event.key === 'Shift') {
322
+ this.editor.querySelector('#canvas')?.classList.remove('shift-held');
323
+
324
+ // Toggle back to move mode mid-drag (nodes)
325
+ if (this.editor.isDragging && this.currentDragIsCopy) {
326
+ this.revertShiftDragCopy();
327
+ requestAnimationFrame(() => this.updateDragPositions());
328
+ }
329
+ }
330
+
331
+ // Forward to editor for action drag shift-copy
332
+ this.editor.handleKeyUp(event);
333
+ }
334
+
335
+ private handleWindowBlur(): void {
336
+ this.editor.querySelector('#canvas')?.classList.remove('shift-held');
337
+
338
+ // Revert copy mode if blur happens mid-drag (keyup may never fire)
339
+ if (this.editor.isDragging && this.currentDragIsCopy) {
340
+ this.revertShiftDragCopy();
341
+ requestAnimationFrame(() => this.updateDragPositions());
342
+ }
343
+
344
+ // Forward to editor for action drag blur handling
345
+ this.editor.handleWindowBlur();
346
+ }
347
+
348
+ private updateSelectionBox(event: MouseEvent): void {
349
+ if (!this.editor.selectionBox || !this.canvasMouseDown) return;
350
+
351
+ const canvasRect = this.editor.querySelector('#canvas')?.getBoundingClientRect();
352
+ if (!canvasRect) return;
353
+
354
+ const relativeX = (event.clientX - canvasRect.left) / this.editor.zoom;
355
+ const relativeY = (event.clientY - canvasRect.top) / this.editor.zoom;
356
+
357
+ this.editor.selectionBox = {
358
+ ...this.editor.selectionBox,
359
+ endX: relativeX,
360
+ endY: relativeY
361
+ };
362
+
363
+ this.updateSelectedItemsFromBox();
364
+ }
365
+
366
+ private updateSelectedItemsFromBox(): void {
367
+ if (!this.editor.selectionBox) return;
368
+
369
+ const newSelection = new Set<string>();
370
+
371
+ const boxLeft = Math.min(this.editor.selectionBox.startX, this.editor.selectionBox.endX);
372
+ const boxTop = Math.min(this.editor.selectionBox.startY, this.editor.selectionBox.endY);
373
+ const boxRight = Math.max(this.editor.selectionBox.startX, this.editor.selectionBox.endX);
374
+ const boxBottom = Math.max(
375
+ this.editor.selectionBox.startY,
376
+ this.editor.selectionBox.endY
377
+ );
378
+
379
+ // Check nodes
380
+ this.editor.definition?.nodes.forEach((node) => {
381
+ const nodeElement = this.editor.querySelector(
382
+ `[id="${node.uuid}"]`
383
+ ) as HTMLElement;
384
+ if (nodeElement) {
385
+ const position = this.editor.definition._ui?.nodes[node.uuid]?.position;
386
+ if (position) {
387
+ const canvasRect =
388
+ this.editor.querySelector('#canvas')?.getBoundingClientRect();
389
+
390
+ if (canvasRect) {
391
+ const nodeLeft = position.left;
392
+ const nodeTop = position.top;
393
+ const nodeRight = nodeLeft + nodeElement.offsetWidth;
394
+ const nodeBottom = nodeTop + nodeElement.offsetHeight;
395
+
396
+ if (
397
+ boxLeft < nodeRight &&
398
+ boxRight > nodeLeft &&
399
+ boxTop < nodeBottom &&
400
+ boxBottom > nodeTop
401
+ ) {
402
+ newSelection.add(node.uuid);
403
+ }
404
+ }
405
+ }
406
+ }
407
+ });
408
+
409
+ // Check sticky notes
410
+ const stickies = this.editor.definition?._ui?.stickies || {};
411
+ Object.entries(stickies).forEach(([uuid, sticky]) => {
412
+ if (sticky.position) {
413
+ const stickyElement = this.editor.querySelector(
414
+ `temba-sticky-note[uuid="${uuid}"]`
415
+ ) as HTMLElement;
416
+
417
+ if (stickyElement) {
418
+ const width = stickyElement.clientWidth;
419
+ const height = stickyElement.clientHeight;
420
+
421
+ const stickyLeft = sticky.position.left;
422
+ const stickyTop = sticky.position.top;
423
+ const stickyRight = stickyLeft + width;
424
+ const stickyBottom = stickyTop + height;
425
+
426
+ if (
427
+ boxLeft < stickyRight &&
428
+ boxRight > stickyLeft &&
429
+ boxTop < stickyBottom &&
430
+ boxBottom > stickyTop
431
+ ) {
432
+ newSelection.add(uuid);
433
+ }
434
+ }
435
+ }
436
+ });
437
+
438
+ this.editor.selectedItems = newSelection;
439
+ }
440
+
441
+ public renderSelectionBox(): TemplateResult | string {
442
+ if (!this.editor.selectionBox || !this.editor.isSelecting) return '';
443
+
444
+ const left = Math.min(this.editor.selectionBox.startX, this.editor.selectionBox.endX);
445
+ const top = Math.min(this.editor.selectionBox.startY, this.editor.selectionBox.endY);
446
+ const width = Math.abs(this.editor.selectionBox.endX - this.editor.selectionBox.startX);
447
+ const height = Math.abs(this.editor.selectionBox.endY - this.editor.selectionBox.startY);
448
+
449
+ return html`<div
450
+ class="selection-box"
451
+ style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
452
+ ></div>`;
453
+ }
454
+
455
+ private handleCanvasTouchStart(event: TouchEvent): void {
456
+ this.markTouchDevice();
457
+
458
+ const touch = event.touches[0];
459
+ if (!touch) return;
460
+
461
+ const target = event.target as HTMLElement;
462
+ if (target.closest('.draggable')) return;
463
+ if (target.id !== 'canvas' && target.id !== 'grid') return;
464
+
465
+ if (event.touches.length >= 2) {
466
+ this.canvasMouseDown = false;
467
+ this.editor.isSelecting = false;
468
+ this.editor.selectionBox = null;
469
+
470
+ this.isTwoFingerPanning = true;
471
+ this.twoFingerOnCanvas = true;
472
+ this.twoFingerDidPan = false;
473
+ this.twoFingerStartMidX =
474
+ (event.touches[0].clientX + event.touches[1].clientX) / 2;
475
+ this.twoFingerStartMidY =
476
+ (event.touches[0].clientY + event.touches[1].clientY) / 2;
477
+ this.lastPanX = this.twoFingerStartMidX;
478
+ this.lastPanY = this.twoFingerStartMidY;
479
+ return;
480
+ }
481
+
482
+ if (this.editor.isReadOnly()) return;
483
+
484
+ this.canvasMouseDown = true;
485
+ this.dragStartPos = { x: touch.clientX, y: touch.clientY };
486
+
487
+ const canvasRect = this.editor.querySelector('#canvas')?.getBoundingClientRect();
488
+ if (canvasRect) {
489
+ this.editor.selectedItems.clear();
490
+
491
+ const relativeX = (touch.clientX - canvasRect.left) / this.editor.zoom;
492
+ const relativeY = (touch.clientY - canvasRect.top) / this.editor.zoom;
493
+
494
+ this.editor.selectionBox = {
495
+ startX: relativeX,
496
+ startY: relativeY,
497
+ endX: relativeX,
498
+ endY: relativeY
499
+ };
500
+ }
501
+
502
+ event.preventDefault();
503
+ }
504
+
505
+ /* c8 ignore stop */
506
+
507
+ private handleMouseMove(event: MouseEvent): void {
508
+ // Handle selection box drawing
509
+ if (this.canvasMouseDown && !this.isMouseDown) {
510
+ this.editor.isSelecting = true;
511
+ this.updateSelectionBox(event);
512
+ this.editor.requestUpdate();
513
+ return;
514
+ }
515
+
516
+ if (this.editor.plumber.connectionDragging) {
517
+ this.activeDragIsTouch = false;
518
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
519
+ this.startAutoScroll();
520
+
521
+ const targetNode = document.querySelector('temba-flow-node:hover');
522
+
523
+ document.querySelectorAll('temba-flow-node').forEach((node) => {
524
+ node.classList.remove(
525
+ 'connection-target-valid',
526
+ 'connection-target-invalid'
527
+ );
528
+ });
529
+
530
+ if (targetNode) {
531
+ this.editor.targetId = targetNode.getAttribute('uuid');
532
+ this.editor.isValidTarget = this.editor.targetId !== this.editor.dragFromNodeId;
533
+
534
+ if (this.editor.isValidTarget) {
535
+ targetNode.classList.add('connection-target-valid');
536
+ } else {
537
+ targetNode.classList.add('connection-target-invalid');
538
+ }
539
+
540
+ this.editor.connectionPlaceholder = null;
541
+ } else {
542
+ this.editor.targetId = null;
543
+ this.editor.isValidTarget = true;
544
+
545
+ const canvas = this.editor.querySelector('#canvas');
546
+ if (canvas) {
547
+ const canvasRect = canvas.getBoundingClientRect();
548
+ const relativeX = (event.clientX - canvasRect.left) / this.editor.zoom;
549
+ const relativeY = (event.clientY - canvasRect.top) / this.editor.zoom;
550
+
551
+ const placeholderWidth = 200;
552
+ const placeholderHeight = 64;
553
+ const arrowLength = ARROW_LENGTH;
554
+ const cursorGap = CURSOR_GAP;
555
+
556
+ const dragUp =
557
+ this.editor.connectionSourceY != null
558
+ ? relativeY < this.editor.connectionSourceY
559
+ : false;
560
+
561
+ let top: number;
562
+ if (dragUp) {
563
+ top = relativeY + cursorGap - placeholderHeight;
564
+ } else {
565
+ top = relativeY - cursorGap + arrowLength;
566
+ }
567
+
568
+ this.editor.connectionPlaceholder = {
569
+ position: {
570
+ left: relativeX - placeholderWidth / 2,
571
+ top
572
+ },
573
+ visible: true,
574
+ dragUp
575
+ };
576
+ }
577
+ }
578
+
579
+ this.editor.requestUpdate();
580
+ }
581
+
582
+ // Handle item dragging
583
+ if (!this.isMouseDown || !this.editor.currentDragItem) return;
584
+
585
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
586
+
587
+ const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
588
+ const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
589
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
590
+
591
+ if (!this.editor.isDragging && distance > DRAG_THRESHOLD) {
592
+ this.editor.isDragging = true;
593
+ this.startAutoScroll();
594
+
595
+ this.originalDragItem = { ...this.editor.currentDragItem };
596
+ this.originalSelectedItems = new Set(this.editor.selectedItems);
597
+
598
+ if (this.shiftDragCopy || event.shiftKey) {
599
+ this.performShiftDragCopy();
600
+ this.shiftDragCopy = false;
601
+ } else {
602
+ this.showDragHint();
603
+ }
604
+ }
605
+
606
+ if (this.editor.isDragging) {
607
+ this.updateDragPositions();
608
+ }
609
+ }
610
+
611
+ private performShiftDragCopy(): void {
612
+ if (!this.originalDragItem) return;
613
+
614
+ const itemsToCopy =
615
+ this.originalSelectedItems?.has(this.originalDragItem.uuid) &&
616
+ (this.originalSelectedItems?.size ?? 0) > 1
617
+ ? Array.from(this.originalSelectedItems!)
618
+ : [this.originalDragItem.uuid];
619
+
620
+ if (itemsToCopy.length === 0) return;
621
+
622
+ const uuidMapping = getStore().getState().duplicateNodes(itemsToCopy);
623
+
624
+ for (const uuid of itemsToCopy) {
625
+ const newUuid = uuidMapping[uuid];
626
+ if (newUuid && !this.editor.copiedItemUuids.includes(newUuid)) {
627
+ this.editor.copiedItemUuids.push(newUuid);
628
+ }
629
+ }
630
+ this.currentDragIsCopy = true;
631
+
632
+ for (const uuid of itemsToCopy) {
633
+ const element = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
634
+ const type =
635
+ element?.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
636
+ const position = this.getPosition(uuid, type);
637
+ if (element && position) {
638
+ element.style.left = `${position.left}px`;
639
+ element.style.top = `${position.top}px`;
640
+ }
641
+ }
642
+ this.editor.plumber.revalidate(itemsToCopy);
643
+ for (const uuid of itemsToCopy) {
644
+ const element = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
645
+ if (element) {
646
+ void element.offsetHeight;
647
+ element.classList.remove('dragging');
648
+ }
649
+ }
650
+
651
+ const newDragUuid = uuidMapping[this.originalDragItem.uuid];
652
+ if (newDragUuid) {
653
+ this.editor.currentDragItem = {
654
+ ...this.originalDragItem,
655
+ uuid: newDragUuid
656
+ };
657
+ }
658
+
659
+ if ((this.originalSelectedItems?.size ?? 0) > 1) {
660
+ const newSelectedItems = new Set<string>();
661
+ for (const uuid of this.originalSelectedItems!) {
662
+ const newUuid = uuidMapping[uuid];
663
+ newSelectedItems.add(newUuid || uuid);
664
+ }
665
+ this.editor.selectedItems = newSelectedItems;
666
+ }
667
+ }
668
+
669
+ private markCopyElements(): void {
670
+ for (const uuid of this.editor.copiedItemUuids) {
671
+ const el = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
672
+ el?.classList.add('drag-copy');
673
+ }
674
+ }
675
+
676
+ private revertShiftDragCopy(): void {
677
+ if (!this.originalDragItem) return;
678
+
679
+ if (this.editor.copiedItemUuids.length > 0) {
680
+ const nodeUuids = this.editor.copiedItemUuids.filter((uuid) =>
681
+ this.editor.definition.nodes.some((n) => n.uuid === uuid)
682
+ );
683
+ const stickyUuids = this.editor.copiedItemUuids.filter(
684
+ (uuid) => this.editor.definition._ui?.stickies?.[uuid]
685
+ );
686
+
687
+ if (nodeUuids.length > 0) {
688
+ getStore().getState().removeNodes(nodeUuids);
689
+ }
690
+ if (stickyUuids.length > 0) {
691
+ getStore().getState().removeStickyNotes(stickyUuids);
692
+ }
693
+ this.editor.copiedItemUuids = [];
694
+ }
695
+
696
+ this.currentDragIsCopy = false;
697
+
698
+ getStore().getState().setDirtyDate(null);
699
+
700
+ this.editor.currentDragItem = { ...this.originalDragItem };
701
+ if (this.originalSelectedItems) {
702
+ this.editor.selectedItems = new Set(this.originalSelectedItems);
703
+ }
704
+ }
705
+
706
+ private updateDragPositions(): void {
707
+ if (!this.editor.currentDragItem || !this.lastPointerPos) return;
708
+
709
+ const deltaX =
710
+ (this.lastPointerPos.clientX -
711
+ this.dragStartPos.x +
712
+ this.autoScrollDeltaX) /
713
+ this.editor.zoom;
714
+ const deltaY =
715
+ (this.lastPointerPos.clientY -
716
+ this.dragStartPos.y +
717
+ this.autoScrollDeltaY) /
718
+ this.editor.zoom;
719
+
720
+ const itemsToMove =
721
+ this.editor.selectedItems.has(this.editor.currentDragItem.uuid) &&
722
+ this.editor.selectedItems.size > 1
723
+ ? Array.from(this.editor.selectedItems)
724
+ : [this.editor.currentDragItem.uuid];
725
+
726
+ itemsToMove.forEach((uuid) => {
727
+ const element = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
728
+ if (element) {
729
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
730
+ const position = this.getPosition(uuid, type);
731
+
732
+ if (position) {
733
+ element.style.left = `${position.left + deltaX}px`;
734
+ element.style.top = `${position.top + deltaY}px`;
735
+ element.classList.add('dragging');
736
+ if (this.currentDragIsCopy) {
737
+ element.classList.add('drag-copy');
738
+ }
739
+ }
740
+ }
741
+ });
742
+
743
+ this.editor.plumber.revalidate(itemsToMove);
744
+ }
745
+
746
+ private startAutoScroll(): void {
747
+ if (this.autoScrollAnimationId !== null) return;
748
+
749
+ const editorEl = this.editor.querySelector('#editor') as HTMLElement;
750
+ if (!editorEl) return;
751
+
752
+ const tick = () => {
753
+ if (
754
+ (!this.editor.isDragging && !this.editor.plumber?.connectionDragging) ||
755
+ !this.lastPointerPos
756
+ ) {
757
+ this.autoScrollAnimationId = null;
758
+ return;
759
+ }
760
+
761
+ const editorRect = editorEl.getBoundingClientRect();
762
+ const mouseX = this.lastPointerPos.clientX;
763
+ const mouseY = this.lastPointerPos.clientY;
764
+ const edgeZone = this.activeDragIsTouch
765
+ ? AUTO_SCROLL_EDGE_ZONE_TOUCH
766
+ : AUTO_SCROLL_EDGE_ZONE;
767
+
768
+ let scrollDx = 0;
769
+ let scrollDy = 0;
770
+
771
+ // Left edge (including beyond)
772
+ const distFromLeft = mouseX - editorRect.left;
773
+ if (distFromLeft < edgeZone) {
774
+ const beyond = distFromLeft < 0;
775
+ const ratio = Math.min(1, 1 - distFromLeft / edgeZone);
776
+ const speed =
777
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
778
+ scrollDx = -(ratio * speed);
779
+ }
780
+
781
+ // Right edge (including beyond)
782
+ const distFromRight = editorRect.right - mouseX;
783
+ if (distFromRight < edgeZone) {
784
+ const beyond = distFromRight < 0;
785
+ const ratio = Math.min(1, 1 - distFromRight / edgeZone);
786
+ const speed =
787
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
788
+ scrollDx = ratio * speed;
789
+ }
790
+
791
+ const distFromTop = mouseY - editorRect.top;
792
+ // Top edge (including beyond)
793
+ if (distFromTop < edgeZone) {
794
+ const beyond = distFromTop < 0;
795
+ const ratio = Math.min(1, 1 - distFromTop / edgeZone);
796
+ const speed =
797
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
798
+ scrollDy = -(ratio * speed);
799
+ }
800
+
801
+ // Bottom edge (including beyond)
802
+ const distFromBottom = editorRect.bottom - mouseY;
803
+ if (distFromBottom < edgeZone) {
804
+ const beyond = distFromBottom < 0;
805
+ const ratio = Math.min(1, 1 - distFromBottom / edgeZone);
806
+ const speed =
807
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
808
+ scrollDy = ratio * speed;
809
+ }
810
+
811
+ if (scrollDx !== 0 || scrollDy !== 0) {
812
+ const beforeScrollLeft = editorEl.scrollLeft;
813
+ const beforeScrollTop = editorEl.scrollTop;
814
+
815
+ if (scrollDx > 0 || scrollDy > 0) {
816
+ const neededWidth =
817
+ (editorEl.scrollLeft + editorEl.clientWidth + scrollDx) / this.editor.zoom;
818
+ const neededHeight =
819
+ (editorEl.scrollTop + editorEl.clientHeight + scrollDy) / this.editor.zoom;
820
+ getStore()?.getState()?.expandCanvas(neededWidth, neededHeight);
821
+ }
822
+
823
+ editorEl.scrollLeft += scrollDx;
824
+ editorEl.scrollTop += scrollDy;
825
+
826
+ const actualDx = editorEl.scrollLeft - beforeScrollLeft;
827
+ const actualDy = editorEl.scrollTop - beforeScrollTop;
828
+ this.autoScrollDeltaX += actualDx;
829
+ this.autoScrollDeltaY += actualDy;
830
+
831
+ if (actualDx !== 0 || actualDy !== 0) {
832
+ this.updateDragPositions();
833
+ }
834
+ }
835
+
836
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
837
+ };
838
+
839
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
840
+ }
841
+
842
+ private stopAutoScroll(): void {
843
+ if (this.autoScrollAnimationId !== null) {
844
+ cancelAnimationFrame(this.autoScrollAnimationId);
845
+ this.autoScrollAnimationId = null;
846
+ }
847
+ }
848
+
849
+ private handleMouseUp(event: MouseEvent): void {
850
+ // Handle selection box completion
851
+ if (this.canvasMouseDown && this.editor.isSelecting) {
852
+ this.editor.isSelecting = false;
853
+ this.editor.selectionBox = null;
854
+ this.canvasMouseDown = false;
855
+ this.editor.requestUpdate();
856
+ return;
857
+ }
858
+
859
+ // Handle canvas click (clear selection)
860
+ if (this.canvasMouseDown && !this.editor.isSelecting) {
861
+ this.canvasMouseDown = false;
862
+ return;
863
+ }
864
+
865
+ // Handle item drag completion
866
+ if (!this.isMouseDown || !this.editor.currentDragItem) return;
867
+
868
+ this.stopAutoScroll();
869
+
870
+ if (this.editor.isDragging) {
871
+ const deltaX =
872
+ (event.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
873
+ this.editor.zoom;
874
+ const deltaY =
875
+ (event.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
876
+ this.editor.zoom;
877
+
878
+ const itemsToMove =
879
+ this.editor.selectedItems.has(this.editor.currentDragItem.uuid) &&
880
+ this.editor.selectedItems.size > 1
881
+ ? Array.from(this.editor.selectedItems)
882
+ : [this.editor.currentDragItem.uuid];
883
+
884
+ const newPositions: { [uuid: string]: FlowPosition } = {};
885
+
886
+ itemsToMove.forEach((uuid) => {
887
+ const type = this.editor.definition.nodes.find((node) => node.uuid === uuid)
888
+ ? 'node'
889
+ : 'sticky';
890
+ const position = this.getPosition(uuid, type);
891
+
892
+ if (position) {
893
+ const newLeft = position.left + deltaX;
894
+ const newTop = position.top + deltaY;
895
+
896
+ const snappedLeft = snapToGrid(newLeft);
897
+ const snappedTop = snapToGrid(newTop);
898
+
899
+ const newPosition = { left: snappedLeft, top: snappedTop };
900
+ newPositions[uuid] = newPosition;
901
+
902
+ const element = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
903
+ if (element) {
904
+ element.classList.remove('dragging', 'drag-copy');
905
+ element.style.left = `${snappedLeft}px`;
906
+ element.style.top = `${snappedTop}px`;
907
+ }
908
+ }
909
+ });
910
+
911
+ if (Object.keys(newPositions).length > 0) {
912
+ if (this.currentDragIsCopy) {
913
+ this.editor.pendingTimer.pending = true;
914
+ this.editor.capturePositionsOnce();
915
+ }
916
+
917
+ getStore().getState().updateCanvasPositions(newPositions);
918
+
919
+ setTimeout(() => {
920
+ this.editor.checkCollisionsAndReflow(itemsToMove);
921
+ this.editor.plumber.repaintEverything();
922
+ }, 0);
923
+ }
924
+
925
+ if (this.currentDragIsCopy) {
926
+ this.editor.pendingTimer.start();
927
+ }
928
+
929
+ this.editor.selectedItems.clear();
930
+ }
931
+
932
+ // Reset all drag state
933
+ this.hideDragHint();
934
+ this.editor.isDragging = false;
935
+ this.isMouseDown = false;
936
+ this.shiftDragCopy = false;
937
+ this.currentDragIsCopy = false;
938
+ this.editor.currentDragItem = null;
939
+ this.originalDragItem = null;
940
+ this.originalSelectedItems = null;
941
+ this.canvasMouseDown = false;
942
+ this.autoScrollDeltaX = 0;
943
+ this.autoScrollDeltaY = 0;
944
+ this.lastPointerPos = null;
945
+ }
946
+
947
+ /* c8 ignore start -- touch-only handlers */
948
+
949
+ private handleTouchMove(event: TouchEvent): void {
950
+ // --- Two-finger panning ---
951
+ if (event.touches.length >= 2) {
952
+ event.preventDefault();
953
+ const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
954
+ const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
955
+
956
+ if (this.isTwoFingerPanning) {
957
+ const dx = this.lastPanX - midX;
958
+ const dy = this.lastPanY - midY;
959
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
960
+ this.twoFingerDidPan = true;
961
+ }
962
+ const editorEl = this.editor.querySelector('#editor') as HTMLElement;
963
+ if (editorEl) {
964
+ editorEl.scrollBy(dx, dy);
965
+ }
966
+ }
967
+
968
+ this.canvasMouseDown = false;
969
+ this.editor.isSelecting = false;
970
+ this.editor.selectionBox = null;
971
+
972
+ this.isTwoFingerPanning = true;
973
+ this.lastPanX = midX;
974
+ this.lastPanY = midY;
975
+ return;
976
+ }
977
+
978
+ const touch = event.touches[0];
979
+ if (!touch) return;
980
+
981
+ // --- Selection box drawing ---
982
+ if (this.canvasMouseDown && !this.isMouseDown) {
983
+ event.preventDefault();
984
+ this.editor.isSelecting = true;
985
+
986
+ const canvasRect = this.editor.querySelector('#canvas')?.getBoundingClientRect();
987
+ if (canvasRect && this.editor.selectionBox) {
988
+ this.editor.selectionBox = {
989
+ ...this.editor.selectionBox,
990
+ endX: (touch.clientX - canvasRect.left) / this.editor.zoom,
991
+ endY: (touch.clientY - canvasRect.top) / this.editor.zoom
992
+ };
993
+ this.updateSelectedItemsFromBox();
994
+ }
995
+
996
+ this.editor.requestUpdate();
997
+ return;
998
+ }
999
+
1000
+ // --- Connection dragging ---
1001
+ if (this.editor.plumber.connectionDragging) {
1002
+ event.preventDefault();
1003
+
1004
+ const targetNode = this.editor.findTargetNodeAt(touch.clientX, touch.clientY);
1005
+
1006
+ document.querySelectorAll('temba-flow-node').forEach((node) => {
1007
+ node.classList.remove(
1008
+ 'connection-target-valid',
1009
+ 'connection-target-invalid'
1010
+ );
1011
+ });
1012
+
1013
+ if (targetNode) {
1014
+ this.editor.targetId = targetNode.getAttribute('uuid');
1015
+ this.editor.isValidTarget = this.editor.targetId !== this.editor.dragFromNodeId;
1016
+
1017
+ if (this.editor.isValidTarget) {
1018
+ targetNode.classList.add('connection-target-valid');
1019
+ } else {
1020
+ targetNode.classList.add('connection-target-invalid');
1021
+ }
1022
+
1023
+ this.editor.connectionPlaceholder = null;
1024
+ } else {
1025
+ this.editor.targetId = null;
1026
+ this.editor.isValidTarget = true;
1027
+
1028
+ const canvas = this.editor.querySelector('#canvas');
1029
+ if (canvas) {
1030
+ const canvasRect = canvas.getBoundingClientRect();
1031
+ const relativeX = (touch.clientX - canvasRect.left) / this.editor.zoom;
1032
+ const relativeY = (touch.clientY - canvasRect.top) / this.editor.zoom;
1033
+
1034
+ const placeholderWidth = 200;
1035
+ const placeholderHeight = 64;
1036
+ const arrowLength = ARROW_LENGTH;
1037
+ const cursorGap = CURSOR_GAP;
1038
+
1039
+ const dragUp =
1040
+ this.editor.connectionSourceY != null
1041
+ ? relativeY < this.editor.connectionSourceY
1042
+ : false;
1043
+
1044
+ let top: number;
1045
+ if (dragUp) {
1046
+ top = relativeY + cursorGap - placeholderHeight;
1047
+ } else {
1048
+ top = relativeY - cursorGap + arrowLength;
1049
+ }
1050
+
1051
+ this.editor.connectionPlaceholder = {
1052
+ position: {
1053
+ left: relativeX - placeholderWidth / 2,
1054
+ top
1055
+ },
1056
+ visible: true,
1057
+ dragUp
1058
+ };
1059
+ }
1060
+ }
1061
+
1062
+ this.editor.requestUpdate();
1063
+ return;
1064
+ }
1065
+
1066
+ // --- Node/sticky dragging ---
1067
+ if (!this.isMouseDown || !this.editor.currentDragItem) return;
1068
+
1069
+ this.lastPointerPos = { clientX: touch.clientX, clientY: touch.clientY };
1070
+
1071
+ const deltaX = touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
1072
+ const deltaY = touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
1073
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1074
+
1075
+ if (!this.editor.isDragging && distance > DRAG_THRESHOLD) {
1076
+ this.editor.isDragging = true;
1077
+ this.startAutoScroll();
1078
+ }
1079
+
1080
+ if (this.editor.isDragging) {
1081
+ event.preventDefault();
1082
+ this.updateDragPositions();
1083
+ }
1084
+ }
1085
+
1086
+ private handleTouchEnd(event: TouchEvent): void {
1087
+ // --- Two-finger gesture end ---
1088
+ if (this.isTwoFingerPanning) {
1089
+ if (event.touches.length === 0) {
1090
+ const didPan = this.twoFingerDidPan;
1091
+ const onCanvas = this.twoFingerOnCanvas;
1092
+ const midX = this.twoFingerStartMidX;
1093
+ const midY = this.twoFingerStartMidY;
1094
+
1095
+ this.isTwoFingerPanning = false;
1096
+ this.twoFingerOnCanvas = false;
1097
+ this.twoFingerDidPan = false;
1098
+
1099
+ if (!didPan && onCanvas) {
1100
+ this.editor.showContextMenuAt(midX, midY);
1101
+ }
1102
+ }
1103
+ return;
1104
+ }
1105
+
1106
+ const touch = event.changedTouches[0];
1107
+
1108
+ // --- Selection box completion ---
1109
+ if (this.canvasMouseDown && this.editor.isSelecting) {
1110
+ this.editor.isSelecting = false;
1111
+ this.editor.selectionBox = null;
1112
+ this.canvasMouseDown = false;
1113
+ this.editor.requestUpdate();
1114
+ return;
1115
+ }
1116
+
1117
+ // --- Canvas tap (no drag) — clear selection ---
1118
+ if (this.canvasMouseDown && !this.editor.isSelecting) {
1119
+ this.canvasMouseDown = false;
1120
+ return;
1121
+ }
1122
+
1123
+ // --- Connection dragging ---
1124
+ if (this.editor.plumber.connectionDragging) {
1125
+ if (touch) {
1126
+ const targetNode = this.editor.findTargetNodeAt(touch.clientX, touch.clientY);
1127
+ if (targetNode) {
1128
+ this.editor.targetId = targetNode.getAttribute('uuid');
1129
+ this.editor.isValidTarget = this.editor.targetId !== this.editor.dragFromNodeId;
1130
+ }
1131
+ }
1132
+ return;
1133
+ }
1134
+
1135
+ // --- Node/sticky dragging ---
1136
+ if (!this.isMouseDown || !this.editor.currentDragItem) return;
1137
+
1138
+ this.stopAutoScroll();
1139
+
1140
+ if (this.editor.isDragging && touch) {
1141
+ const deltaX =
1142
+ (touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
1143
+ this.editor.zoom;
1144
+ const deltaY =
1145
+ (touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
1146
+ this.editor.zoom;
1147
+
1148
+ const itemsToMove =
1149
+ this.editor.selectedItems.has(this.editor.currentDragItem.uuid) &&
1150
+ this.editor.selectedItems.size > 1
1151
+ ? Array.from(this.editor.selectedItems)
1152
+ : [this.editor.currentDragItem.uuid];
1153
+
1154
+ const newPositions: { [uuid: string]: FlowPosition } = {};
1155
+
1156
+ itemsToMove.forEach((uuid) => {
1157
+ const type = this.editor.definition.nodes.find((node) => node.uuid === uuid)
1158
+ ? 'node'
1159
+ : 'sticky';
1160
+ const position = this.getPosition(uuid, type);
1161
+
1162
+ if (position) {
1163
+ const newLeft = position.left + deltaX;
1164
+ const newTop = position.top + deltaY;
1165
+ const snappedLeft = snapToGrid(newLeft);
1166
+ const snappedTop = snapToGrid(newTop);
1167
+
1168
+ newPositions[uuid] = { left: snappedLeft, top: snappedTop };
1169
+
1170
+ const element = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
1171
+ if (element) {
1172
+ element.classList.remove('dragging');
1173
+ element.style.left = `${snappedLeft}px`;
1174
+ element.style.top = `${snappedTop}px`;
1175
+ }
1176
+ }
1177
+ });
1178
+
1179
+ if (Object.keys(newPositions).length > 0) {
1180
+ getStore().getState().updateCanvasPositions(newPositions);
1181
+
1182
+ setTimeout(() => {
1183
+ this.editor.checkCollisionsAndReflow(itemsToMove);
1184
+ this.editor.plumber.repaintEverything();
1185
+ }, 0);
1186
+ }
1187
+
1188
+ this.editor.selectedItems.clear();
1189
+ }
1190
+
1191
+ // Reset all drag state
1192
+ this.editor.isDragging = false;
1193
+ this.isMouseDown = false;
1194
+ this.editor.currentDragItem = null;
1195
+ this.canvasMouseDown = false;
1196
+ this.autoScrollDeltaX = 0;
1197
+ this.autoScrollDeltaY = 0;
1198
+ this.lastPointerPos = null;
1199
+ }
1200
+
1201
+ private handleTouchCancel(): void {
1202
+ this.isTwoFingerPanning = false;
1203
+ this.editor.isSelecting = false;
1204
+ this.editor.selectionBox = null;
1205
+ this.canvasMouseDown = false;
1206
+
1207
+ if (this.editor.isDragging && this.editor.currentDragItem) {
1208
+ const itemsToReset =
1209
+ this.editor.selectedItems.has(this.editor.currentDragItem.uuid) &&
1210
+ this.editor.selectedItems.size > 1
1211
+ ? Array.from(this.editor.selectedItems)
1212
+ : [this.editor.currentDragItem.uuid];
1213
+ itemsToReset.forEach((uuid) => {
1214
+ const el = this.editor.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
1215
+ if (el) el.classList.remove('dragging');
1216
+ });
1217
+ }
1218
+
1219
+ this.stopAutoScroll();
1220
+ this.editor.isDragging = false;
1221
+ this.isMouseDown = false;
1222
+ this.editor.currentDragItem = null;
1223
+ this.autoScrollDeltaX = 0;
1224
+ this.autoScrollDeltaY = 0;
1225
+ this.lastPointerPos = null;
1226
+
1227
+ document.querySelectorAll('temba-flow-node').forEach((node) => {
1228
+ node.classList.remove(
1229
+ 'connection-target-valid',
1230
+ 'connection-target-invalid'
1231
+ );
1232
+ });
1233
+ this.editor.connectionPlaceholder = null;
1234
+
1235
+ this.editor.requestUpdate();
1236
+ }
1237
+
1238
+ /* c8 ignore stop */
1239
+ }