@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.
- package/CHANGELOG.md +17 -0
- package/dist/temba-components.js +817 -889
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/DragManager.ts +1239 -0
- package/src/flow/Editor.ts +1643 -4148
- package/src/flow/IssuesWindow.ts +73 -0
- package/src/flow/RevisionsWindow.ts +274 -0
- package/src/flow/ZoomManager.ts +544 -0
- package/src/form/select/Select.ts +27 -0
- package/src/interfaces.ts +8 -1
- package/temba-modules.ts +4 -0
- package/run.sh +0 -19
- package/setup-worktree.sh +0 -53
|
@@ -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
|
+
}
|