@nyaruka/temba-components 0.156.6 → 0.156.7
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 +11 -0
- package/dist/temba-components.js +1314 -1391
- 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 +467 -3329
- 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
package/src/flow/Editor.ts
CHANGED
|
@@ -9,20 +9,12 @@ import {
|
|
|
9
9
|
NodeUI
|
|
10
10
|
} from '../store/flow-definition';
|
|
11
11
|
import { getStore, Store } from '../store/Store';
|
|
12
|
-
import {
|
|
13
|
-
AppState,
|
|
14
|
-
FlowIssue,
|
|
15
|
-
fromStore,
|
|
16
|
-
zustand,
|
|
17
|
-
FLOW_SPEC_VERSION
|
|
18
|
-
} from '../store/AppState';
|
|
12
|
+
import { AppState, FlowIssue, fromStore, zustand } from '../store/AppState';
|
|
19
13
|
import { RapidElement } from '../RapidElement';
|
|
20
14
|
import { repeat } from 'lit-html/directives/repeat.js';
|
|
21
15
|
import { CustomEventType, DirtyTrackable, Workspace } from '../interfaces';
|
|
22
16
|
import {
|
|
23
17
|
generateUUID,
|
|
24
|
-
postJSON,
|
|
25
|
-
fetchResults,
|
|
26
18
|
getClasses,
|
|
27
19
|
getCookie,
|
|
28
20
|
setCookie,
|
|
@@ -30,31 +22,14 @@ import {
|
|
|
30
22
|
} from '../utils';
|
|
31
23
|
import { TEMBA_COMPONENTS_VERSION } from '../version';
|
|
32
24
|
import {
|
|
33
|
-
formatIssueMessage,
|
|
34
|
-
getLanguageDisplayName,
|
|
35
25
|
getNodeBounds,
|
|
36
26
|
calculateReflowPositions,
|
|
37
|
-
isRightClick,
|
|
38
27
|
NodeBounds,
|
|
39
28
|
snapToGrid
|
|
40
29
|
} from './utils';
|
|
41
30
|
import { ACTION_CONFIG, NODE_CONFIG } from './config';
|
|
42
31
|
import { calculateLayeredLayout, placeStickyNotes } from './reflow';
|
|
43
|
-
import {
|
|
44
|
-
import { getTranslatableCategoriesForNode } from './categoryLocalization';
|
|
45
|
-
|
|
46
|
-
interface Revision {
|
|
47
|
-
id: number;
|
|
48
|
-
user: {
|
|
49
|
-
id: number;
|
|
50
|
-
username: string;
|
|
51
|
-
first_name: string;
|
|
52
|
-
last_name: string;
|
|
53
|
-
name?: string;
|
|
54
|
-
};
|
|
55
|
-
created_on: string;
|
|
56
|
-
comment?: string;
|
|
57
|
-
}
|
|
32
|
+
import type { RevisionsWindow } from './RevisionsWindow';
|
|
58
33
|
|
|
59
34
|
import { ACTION_GROUP_METADATA } from './types';
|
|
60
35
|
|
|
@@ -62,17 +37,16 @@ import {
|
|
|
62
37
|
Plumber,
|
|
63
38
|
calculateFlowchartPath,
|
|
64
39
|
ARROW_LENGTH,
|
|
65
|
-
ARROW_HALF_WIDTH
|
|
66
|
-
CURSOR_GAP
|
|
40
|
+
ARROW_HALF_WIDTH
|
|
67
41
|
} from './Plumber';
|
|
68
42
|
import { CanvasNode } from './CanvasNode';
|
|
43
|
+
import { DragManager } from './DragManager';
|
|
44
|
+
import { ZoomManager } from './ZoomManager';
|
|
69
45
|
import { Dialog } from '../layout/Dialog';
|
|
70
46
|
|
|
71
47
|
import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
|
|
72
48
|
import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
|
|
73
|
-
import { FloatingWindow } from '../layout/FloatingWindow';
|
|
74
49
|
import { FlowSearch, SearchResult } from './FlowSearch';
|
|
75
|
-
import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
|
|
76
50
|
|
|
77
51
|
export function findNodeForExit(
|
|
78
52
|
definition: FlowDefinition,
|
|
@@ -103,39 +77,6 @@ export interface SelectionBox {
|
|
|
103
77
|
endY: number;
|
|
104
78
|
}
|
|
105
79
|
|
|
106
|
-
const DRAG_THRESHOLD = 5;
|
|
107
|
-
const AUTO_SCROLL_EDGE_ZONE = 150;
|
|
108
|
-
const AUTO_SCROLL_MAX_SPEED = 15;
|
|
109
|
-
const AUTO_SCROLL_BEYOND_MULTIPLIER = 5;
|
|
110
|
-
|
|
111
|
-
type TranslationType = 'property' | 'category';
|
|
112
|
-
|
|
113
|
-
interface TranslationEntry {
|
|
114
|
-
uuid: string;
|
|
115
|
-
type: TranslationType;
|
|
116
|
-
attribute: string;
|
|
117
|
-
from: string;
|
|
118
|
-
to: string | null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface TranslationBundle {
|
|
122
|
-
nodeUuid: string;
|
|
123
|
-
actionUuid?: string;
|
|
124
|
-
translations: TranslationEntry[];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface TranslationModel {
|
|
128
|
-
uuid: string;
|
|
129
|
-
name: string;
|
|
130
|
-
description?: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
interface LocalizationUpdate {
|
|
134
|
-
uuid: string;
|
|
135
|
-
translations: Record<string, string>;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
|
|
139
80
|
export type ToolbarAction =
|
|
140
81
|
| { action: 'view-change'; view: 'flow' | 'table' }
|
|
141
82
|
| { action: 'zoom-in' }
|
|
@@ -143,8 +84,7 @@ export type ToolbarAction =
|
|
|
143
84
|
| { action: 'zoom-to-fit' }
|
|
144
85
|
| { action: 'zoom-to-full' }
|
|
145
86
|
| { action: 'revisions' }
|
|
146
|
-
| { action: 'search' }
|
|
147
|
-
| { action: 'language-change'; isPrimary?: boolean; languageCode?: string };
|
|
87
|
+
| { action: 'search' };
|
|
148
88
|
const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
|
|
149
89
|
|
|
150
90
|
// How long the pending-changes auto-save countdown runs (in ms).
|
|
@@ -223,7 +163,13 @@ export class Editor extends RapidElement {
|
|
|
223
163
|
}
|
|
224
164
|
|
|
225
165
|
// this is the master plumber
|
|
226
|
-
|
|
166
|
+
public plumber: Plumber;
|
|
167
|
+
|
|
168
|
+
// drag/selection manager
|
|
169
|
+
public dragManager: DragManager;
|
|
170
|
+
|
|
171
|
+
// zoom/pan/loupe manager
|
|
172
|
+
public zoomManager: ZoomManager;
|
|
227
173
|
|
|
228
174
|
// timer for debounced saving
|
|
229
175
|
private saveTimer: number | null = null;
|
|
@@ -244,7 +190,7 @@ export class Editor extends RapidElement {
|
|
|
244
190
|
private activityInterval = 100; // Start with 100ms interval for fast initial load
|
|
245
191
|
|
|
246
192
|
@fromStore(zustand, (state: AppState) => state.flowDefinition)
|
|
247
|
-
|
|
193
|
+
public definition!: FlowDefinition;
|
|
248
194
|
|
|
249
195
|
@fromStore(zustand, (state: AppState) => state.simulatorActive)
|
|
250
196
|
private simulatorActive!: boolean;
|
|
@@ -267,23 +213,15 @@ export class Editor extends RapidElement {
|
|
|
267
213
|
@fromStore(zustand, (state: AppState) => state.getCurrentActivity())
|
|
268
214
|
private activityData!: any;
|
|
269
215
|
|
|
270
|
-
@fromStore(
|
|
216
|
+
@fromStore(
|
|
217
|
+
zustand,
|
|
218
|
+
(state: AppState) => state.flowInfo?.issues ?? EMPTY_FLOW_ISSUES
|
|
219
|
+
)
|
|
271
220
|
private flowIssues!: FlowIssue[];
|
|
272
221
|
|
|
273
|
-
// Drag state
|
|
222
|
+
// Drag state (managed by DragManager, kept on Editor for Lit reactivity)
|
|
274
223
|
@state()
|
|
275
|
-
|
|
276
|
-
private isMouseDown = false;
|
|
277
|
-
private shiftDragCopy = false;
|
|
278
|
-
private currentDragIsCopy = false;
|
|
279
|
-
private dragStartPos = { x: 0, y: 0 };
|
|
280
|
-
|
|
281
|
-
// Mid-drag shift toggle: remember originals so we can switch between move/copy
|
|
282
|
-
private originalDragItem: DraggableItem | null = null;
|
|
283
|
-
private originalSelectedItems: Set<string> | null = null;
|
|
284
|
-
|
|
285
|
-
// Drag hint tooltip
|
|
286
|
-
private dragHintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
224
|
+
public isDragging = false;
|
|
287
225
|
|
|
288
226
|
// Public getter for drag state
|
|
289
227
|
public get dragging(): boolean {
|
|
@@ -291,107 +229,54 @@ export class Editor extends RapidElement {
|
|
|
291
229
|
}
|
|
292
230
|
|
|
293
231
|
@state()
|
|
294
|
-
|
|
295
|
-
private startPos = { left: 0, top: 0 };
|
|
296
|
-
|
|
297
|
-
// Auto-scroll state
|
|
298
|
-
private autoScrollAnimationId: number | null = null;
|
|
299
|
-
private autoScrollDeltaX = 0;
|
|
300
|
-
private autoScrollDeltaY = 0;
|
|
301
|
-
private lastPointerPos: { clientX: number; clientY: number } | null = null;
|
|
232
|
+
public currentDragItem: DraggableItem | null = null;
|
|
302
233
|
|
|
303
234
|
// Selection state
|
|
304
235
|
@state()
|
|
305
|
-
|
|
236
|
+
public selectedItems: Set<string> = new Set();
|
|
306
237
|
|
|
307
238
|
@state()
|
|
308
|
-
|
|
239
|
+
public isSelecting = false;
|
|
309
240
|
|
|
310
241
|
@state()
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// Touch device state
|
|
314
|
-
private isTouchDevice = false;
|
|
315
|
-
private isTwoFingerPanning = false;
|
|
316
|
-
private twoFingerDidPan = false;
|
|
317
|
-
private twoFingerStartMidX = 0;
|
|
318
|
-
private twoFingerStartMidY = 0;
|
|
319
|
-
private twoFingerOnCanvas = false;
|
|
320
|
-
private lastPanX = 0;
|
|
321
|
-
private lastPanY = 0;
|
|
242
|
+
public selectionBox: SelectionBox | null = null;
|
|
322
243
|
|
|
323
244
|
@state()
|
|
324
|
-
|
|
245
|
+
public targetId: string | null = null;
|
|
325
246
|
|
|
326
247
|
@state()
|
|
327
|
-
|
|
248
|
+
public sourceId: string | null = null;
|
|
328
249
|
|
|
329
250
|
@state()
|
|
330
|
-
|
|
251
|
+
public dragFromNodeId: string | null = null;
|
|
331
252
|
|
|
332
253
|
@state()
|
|
333
|
-
|
|
254
|
+
public originalConnectionTargetId: string | null = null;
|
|
334
255
|
|
|
335
256
|
@state()
|
|
336
|
-
|
|
257
|
+
public isValidTarget = true;
|
|
337
258
|
|
|
338
259
|
// Canvas-relative source exit position (set at drag start)
|
|
339
|
-
|
|
340
|
-
|
|
260
|
+
public connectionSourceX: number | null = null;
|
|
261
|
+
public connectionSourceY: number | null = null;
|
|
341
262
|
|
|
342
263
|
@state()
|
|
343
264
|
private issuesWindowHidden = true;
|
|
344
265
|
|
|
345
|
-
@state()
|
|
346
|
-
private localizationWindowHidden = true;
|
|
347
|
-
|
|
348
|
-
@state()
|
|
349
|
-
private translationSettingsExpanded = false;
|
|
350
|
-
|
|
351
|
-
@state()
|
|
352
|
-
private autoTranslateDialogOpen = false;
|
|
353
|
-
|
|
354
|
-
@state()
|
|
355
|
-
private autoTranslating = false;
|
|
356
|
-
|
|
357
|
-
@state()
|
|
358
|
-
private autoTranslateModel: TranslationModel | null = null;
|
|
359
|
-
|
|
360
|
-
@state()
|
|
361
|
-
private autoTranslateError: string | null = null;
|
|
362
|
-
|
|
363
266
|
@state()
|
|
364
267
|
private revisionsWindowHidden = true;
|
|
365
268
|
|
|
366
269
|
@state()
|
|
367
|
-
private
|
|
368
|
-
|
|
369
|
-
@state()
|
|
370
|
-
private viewingRevision: Revision | null = null;
|
|
371
|
-
|
|
372
|
-
@state()
|
|
373
|
-
private isLoadingRevisions = false;
|
|
270
|
+
private viewingRevision = false;
|
|
374
271
|
|
|
375
272
|
@state()
|
|
376
|
-
|
|
273
|
+
public isSaving = false;
|
|
377
274
|
|
|
378
275
|
@state()
|
|
379
276
|
private saveError: string | null = null;
|
|
380
277
|
|
|
381
278
|
@state()
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
@state()
|
|
385
|
-
private zoomInitialized = false;
|
|
386
|
-
|
|
387
|
-
@state()
|
|
388
|
-
private zoomFitted = false;
|
|
389
|
-
|
|
390
|
-
// Loupe magnifier state - tracked via direct DOM updates for performance
|
|
391
|
-
private loupeEl: HTMLElement | null = null;
|
|
392
|
-
private loupeContentEl: HTMLElement | null = null;
|
|
393
|
-
private loupeRAF: number | null = null;
|
|
394
|
-
private hiddenTitles: { el: Element; title: string }[] = [];
|
|
279
|
+
public zoom = 1.0;
|
|
395
280
|
|
|
396
281
|
// Non-reactive flag set in willUpdate to suppress the debouncedSave
|
|
397
282
|
// call in updated() when the dirtyDate change comes from a reflow/copy
|
|
@@ -402,7 +287,7 @@ export class Editor extends RapidElement {
|
|
|
402
287
|
|
|
403
288
|
// --- Pending-changes timer (shared by reflow + copy) ---
|
|
404
289
|
|
|
405
|
-
|
|
290
|
+
public pendingTimer = new PendingChangesTimer(
|
|
406
291
|
'Unsaved Changes',
|
|
407
292
|
PENDING_SAVE_DELAY,
|
|
408
293
|
this,
|
|
@@ -417,10 +302,10 @@ export class Editor extends RapidElement {
|
|
|
417
302
|
private pendingPositions: Record<string, FlowPosition> | null = null;
|
|
418
303
|
|
|
419
304
|
/** UUIDs of items created by shift+drag copy during the pending window. */
|
|
420
|
-
|
|
305
|
+
public copiedItemUuids: string[] = [];
|
|
421
306
|
|
|
422
307
|
/** Save all current canvas positions if not already saved. */
|
|
423
|
-
|
|
308
|
+
public capturePositionsOnce(): void {
|
|
424
309
|
if (this.pendingPositions) return;
|
|
425
310
|
const saved: Record<string, FlowPosition> = {};
|
|
426
311
|
for (const node of this.definition.nodes) {
|
|
@@ -438,14 +323,7 @@ export class Editor extends RapidElement {
|
|
|
438
323
|
this.pendingPositions = saved;
|
|
439
324
|
}
|
|
440
325
|
|
|
441
|
-
|
|
442
|
-
definition: FlowDefinition;
|
|
443
|
-
dirtyDate: Date | null;
|
|
444
|
-
} | null = null;
|
|
445
|
-
private revisionsBrowseLanguageCode: string | null = null;
|
|
446
|
-
|
|
447
|
-
private deleteDialog: Dialog | null = null;
|
|
448
|
-
private translationCache = new Map<string, string>();
|
|
326
|
+
public deleteDialog: Dialog | null = null;
|
|
449
327
|
|
|
450
328
|
private dirtyAdapter: DirtyTrackable = {
|
|
451
329
|
dirtyMessage:
|
|
@@ -461,13 +339,13 @@ export class Editor extends RapidElement {
|
|
|
461
339
|
|
|
462
340
|
// NodeEditor state - handles both node and action editing
|
|
463
341
|
@state()
|
|
464
|
-
|
|
342
|
+
public editingNode: Node | null = null;
|
|
465
343
|
|
|
466
344
|
@state()
|
|
467
|
-
|
|
345
|
+
public editingNodeUI: NodeUI | null = null;
|
|
468
346
|
|
|
469
347
|
@state()
|
|
470
|
-
|
|
348
|
+
public editingAction: Action | null = null;
|
|
471
349
|
|
|
472
350
|
private dialogOrigin: { x: number; y: number } | null = null;
|
|
473
351
|
|
|
@@ -515,7 +393,7 @@ export class Editor extends RapidElement {
|
|
|
515
393
|
|
|
516
394
|
// Connection placeholder state for dropping connections on empty canvas
|
|
517
395
|
@state()
|
|
518
|
-
|
|
396
|
+
public connectionPlaceholder: {
|
|
519
397
|
position: FlowPosition;
|
|
520
398
|
visible: boolean;
|
|
521
399
|
dragUp?: boolean;
|
|
@@ -528,45 +406,11 @@ export class Editor extends RapidElement {
|
|
|
528
406
|
position: FlowPosition;
|
|
529
407
|
} | null = null;
|
|
530
408
|
|
|
531
|
-
private canvasMouseDown = false;
|
|
532
|
-
|
|
533
|
-
private getAvailableLanguages(): Array<{ code: string; name: string }> {
|
|
534
|
-
// Use languages from workspace if available
|
|
535
|
-
if (this.workspace?.languages && this.workspace.languages.length > 0) {
|
|
536
|
-
return this.workspace.languages
|
|
537
|
-
.map((code) => ({ code, name: getLanguageDisplayName(code) }))
|
|
538
|
-
.filter((lang) => lang.code && lang.name);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Fall back to flow definition languages if available
|
|
542
|
-
if (
|
|
543
|
-
this.definition?._ui?.languages &&
|
|
544
|
-
this.definition._ui.languages.length > 0
|
|
545
|
-
) {
|
|
546
|
-
return this.definition._ui.languages.map((lang: any) => ({
|
|
547
|
-
code: typeof lang === 'string' ? lang : lang.iso || lang.code,
|
|
548
|
-
name: typeof lang === 'string' ? lang : lang.name
|
|
549
|
-
}));
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// No languages available
|
|
553
|
-
return [];
|
|
554
|
-
}
|
|
555
|
-
|
|
556
409
|
// Bound event handlers to maintain proper 'this' context
|
|
557
|
-
private boundMouseMove = this.handleMouseMove.bind(this);
|
|
558
|
-
private boundMouseUp = this.handleMouseUp.bind(this);
|
|
559
|
-
private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
|
|
560
|
-
private boundKeyDown = this.handleKeyDown.bind(this);
|
|
561
|
-
private boundKeyUp = this.handleKeyUp.bind(this);
|
|
562
|
-
private boundWindowBlur = this.handleWindowBlur.bind(this);
|
|
563
410
|
private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
|
|
564
|
-
private boundWheel = this.handleWheel
|
|
565
|
-
private
|
|
566
|
-
|
|
567
|
-
private boundTouchCancel = this.handleTouchCancel.bind(this);
|
|
568
|
-
private boundCanvasTouchStart = this.handleCanvasTouchStart.bind(this);
|
|
569
|
-
private boundWindowResize = this.updateZoomControlPositioning.bind(this);
|
|
411
|
+
private boundWheel = (e: WheelEvent) => this.zoomManager.handleWheel(e);
|
|
412
|
+
private boundWindowResize = () =>
|
|
413
|
+
this.zoomManager.updateZoomControlPositioning();
|
|
570
414
|
|
|
571
415
|
static get styles() {
|
|
572
416
|
return css`
|
|
@@ -989,7 +833,6 @@ export class Editor extends RapidElement {
|
|
|
989
833
|
color: #6b7280;
|
|
990
834
|
}
|
|
991
835
|
|
|
992
|
-
|
|
993
836
|
.translation-settings-arrow {
|
|
994
837
|
width: 8px;
|
|
995
838
|
height: 8px;
|
|
@@ -1305,8 +1148,12 @@ export class Editor extends RapidElement {
|
|
|
1305
1148
|
}
|
|
1306
1149
|
|
|
1307
1150
|
@keyframes drag-hint-in {
|
|
1308
|
-
from {
|
|
1309
|
-
|
|
1151
|
+
from {
|
|
1152
|
+
opacity: 0;
|
|
1153
|
+
}
|
|
1154
|
+
to {
|
|
1155
|
+
opacity: 1;
|
|
1156
|
+
}
|
|
1310
1157
|
}
|
|
1311
1158
|
|
|
1312
1159
|
.reflow-card {
|
|
@@ -1378,6 +1225,8 @@ export class Editor extends RapidElement {
|
|
|
1378
1225
|
|
|
1379
1226
|
constructor() {
|
|
1380
1227
|
super();
|
|
1228
|
+
this.dragManager = new DragManager(this);
|
|
1229
|
+
this.zoomManager = new ZoomManager(this);
|
|
1381
1230
|
}
|
|
1382
1231
|
|
|
1383
1232
|
protected firstUpdated(
|
|
@@ -1393,12 +1242,15 @@ export class Editor extends RapidElement {
|
|
|
1393
1242
|
// Eagerly detect touch capability so hover-only controls are visible
|
|
1394
1243
|
// from the start and scrollbar/touch-action CSS is applied immediately.
|
|
1395
1244
|
if (navigator.maxTouchPoints > 0) {
|
|
1396
|
-
this.
|
|
1245
|
+
this.querySelector('#canvas')?.classList.add('touch-device');
|
|
1246
|
+
this.querySelector('#editor')?.classList.add('touch-device');
|
|
1397
1247
|
}
|
|
1398
|
-
this.updateZoomControlPositioning();
|
|
1399
|
-
this.
|
|
1400
|
-
|
|
1401
|
-
|
|
1248
|
+
this.zoomManager.updateZoomControlPositioning();
|
|
1249
|
+
this.zoomManager.setLoupeElements(
|
|
1250
|
+
this.querySelector('#loupe') as HTMLElement,
|
|
1251
|
+
this.querySelector('#loupe-content') as HTMLElement
|
|
1252
|
+
);
|
|
1253
|
+
this.zoomManager.initLoupe();
|
|
1402
1254
|
if (changes.has('flow') && this.flow) {
|
|
1403
1255
|
// Defer revision fetch so reactive state changes in fetchRevisions()
|
|
1404
1256
|
// don't run inside firstUpdated().
|
|
@@ -1407,7 +1259,6 @@ export class Editor extends RapidElement {
|
|
|
1407
1259
|
return;
|
|
1408
1260
|
}
|
|
1409
1261
|
getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
|
|
1410
|
-
this.fetchRevisions();
|
|
1411
1262
|
}, 0);
|
|
1412
1263
|
}
|
|
1413
1264
|
|
|
@@ -1430,11 +1281,6 @@ export class Editor extends RapidElement {
|
|
|
1430
1281
|
}
|
|
1431
1282
|
|
|
1432
1283
|
private makeConnection(info) {
|
|
1433
|
-
this.stopAutoScroll();
|
|
1434
|
-
this.autoScrollDeltaX = 0;
|
|
1435
|
-
this.autoScrollDeltaY = 0;
|
|
1436
|
-
this.lastPointerPos = null;
|
|
1437
|
-
|
|
1438
1284
|
if (this.sourceId && this.targetId && this.isValidTarget) {
|
|
1439
1285
|
// going to the same target, just put it back
|
|
1440
1286
|
if (info.target.id === this.targetId) {
|
|
@@ -1535,7 +1381,7 @@ export class Editor extends RapidElement {
|
|
|
1535
1381
|
}
|
|
1536
1382
|
|
|
1537
1383
|
// Pre-sync zoom state so we don't mutate reactive state in updated().
|
|
1538
|
-
this.restoreInitialZoomFromSettings();
|
|
1384
|
+
this.zoomManager.restoreInitialZoomFromSettings();
|
|
1539
1385
|
}
|
|
1540
1386
|
|
|
1541
1387
|
if (changes.has('dirtyDate')) {
|
|
@@ -1557,29 +1403,13 @@ export class Editor extends RapidElement {
|
|
|
1557
1403
|
}
|
|
1558
1404
|
}
|
|
1559
1405
|
|
|
1560
|
-
private restoreInitialZoomFromSettings(): void {
|
|
1561
|
-
if (this.zoomInitialized || !this.definition) {
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
const savedZoom = this.getFlowSetting<number>('zoom');
|
|
1566
|
-
if (typeof savedZoom === 'number' && Number.isFinite(savedZoom)) {
|
|
1567
|
-
const clamped = Math.max(0.3, Math.min(1.0, Math.round(savedZoom * 100) / 100));
|
|
1568
|
-
this.zoom = clamped;
|
|
1569
|
-
if (this.plumber) {
|
|
1570
|
-
this.plumber.zoom = clamped;
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
this.zoomInitialized = true;
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
1406
|
private setSimulatorTabHidden(hidden: boolean): void {
|
|
1577
|
-
const simulator = document.querySelector(
|
|
1407
|
+
const simulator = document.querySelector(
|
|
1408
|
+
'temba-simulator'
|
|
1409
|
+
) as HTMLElement & {
|
|
1578
1410
|
shadowRoot?: ShadowRoot;
|
|
1579
1411
|
};
|
|
1580
|
-
const phoneTab = simulator?.shadowRoot?.querySelector(
|
|
1581
|
-
'#phone-tab'
|
|
1582
|
-
) as any;
|
|
1412
|
+
const phoneTab = simulator?.shadowRoot?.querySelector('#phone-tab') as any;
|
|
1583
1413
|
if (phoneTab) {
|
|
1584
1414
|
phoneTab.hidden = hidden;
|
|
1585
1415
|
}
|
|
@@ -1596,10 +1426,14 @@ export class Editor extends RapidElement {
|
|
|
1596
1426
|
this.setSimulatorTabHidden(!this.revisionsWindowHidden);
|
|
1597
1427
|
}
|
|
1598
1428
|
if (changes.has('canvasSize')) {
|
|
1599
|
-
this.updateZoomControlPositioning();
|
|
1429
|
+
this.zoomManager.updateZoomControlPositioning();
|
|
1600
1430
|
}
|
|
1601
1431
|
|
|
1602
|
-
if (
|
|
1432
|
+
if (
|
|
1433
|
+
changes.has('showMessageTable') &&
|
|
1434
|
+
!this.showMessageTable &&
|
|
1435
|
+
this.plumber
|
|
1436
|
+
) {
|
|
1603
1437
|
// Canvas was re-added to the DOM; rebind the plumber, listeners, and repaint
|
|
1604
1438
|
requestAnimationFrame(() => {
|
|
1605
1439
|
const canvas = this.querySelector('#canvas');
|
|
@@ -1607,23 +1441,18 @@ export class Editor extends RapidElement {
|
|
|
1607
1441
|
this.plumber.setContainer(canvas as HTMLElement);
|
|
1608
1442
|
this.plumber.repaintEverything();
|
|
1609
1443
|
canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1610
|
-
canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
|
|
1611
|
-
passive: false
|
|
1612
|
-
});
|
|
1613
1444
|
}
|
|
1614
1445
|
});
|
|
1615
1446
|
}
|
|
1616
1447
|
|
|
1617
1448
|
if (changes.has('showMessageTable')) {
|
|
1618
|
-
this.updateZoomControlPositioning();
|
|
1449
|
+
this.zoomManager.updateZoomControlPositioning();
|
|
1619
1450
|
}
|
|
1620
1451
|
|
|
1621
1452
|
if (changes.has('definition')) {
|
|
1622
1453
|
// defer to avoid triggering a reactive canvasSize update during this cycle
|
|
1623
1454
|
setTimeout(() => this.updateCanvasSize(), 0);
|
|
1624
1455
|
|
|
1625
|
-
this.translationCache.clear();
|
|
1626
|
-
|
|
1627
1456
|
// Start fetching activity data when definition is loaded
|
|
1628
1457
|
if (this.definition?.uuid) {
|
|
1629
1458
|
this.startActivityFetching();
|
|
@@ -1681,10 +1510,6 @@ export class Editor extends RapidElement {
|
|
|
1681
1510
|
this.saveError = null;
|
|
1682
1511
|
}, 0);
|
|
1683
1512
|
}
|
|
1684
|
-
|
|
1685
|
-
if (changes.has('languageCode')) {
|
|
1686
|
-
this.translationCache.clear();
|
|
1687
|
-
}
|
|
1688
1513
|
}
|
|
1689
1514
|
|
|
1690
1515
|
/**
|
|
@@ -1800,9 +1625,6 @@ export class Editor extends RapidElement {
|
|
|
1800
1625
|
if (response.json.revision?.revision !== undefined) {
|
|
1801
1626
|
state.setRevision(response.json.revision.revision);
|
|
1802
1627
|
}
|
|
1803
|
-
|
|
1804
|
-
// Refresh revisions list so the tab visibility stays up to date
|
|
1805
|
-
this.fetchRevisions();
|
|
1806
1628
|
}
|
|
1807
1629
|
|
|
1808
1630
|
getStore().getState().setDirtyDate(null);
|
|
@@ -1916,9 +1738,9 @@ export class Editor extends RapidElement {
|
|
|
1916
1738
|
|
|
1917
1739
|
disconnectedCallback(): void {
|
|
1918
1740
|
super.disconnectedCallback();
|
|
1919
|
-
this.teardownLoupe();
|
|
1741
|
+
this.zoomManager.teardownLoupe();
|
|
1920
1742
|
getStore()?.getState().setFlushSave(null);
|
|
1921
|
-
this.
|
|
1743
|
+
this.dragManager.teardownListeners();
|
|
1922
1744
|
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
1923
1745
|
const store = document.querySelector('temba-store') as Store;
|
|
1924
1746
|
if (store?.markClean) {
|
|
@@ -1933,21 +1755,11 @@ export class Editor extends RapidElement {
|
|
|
1933
1755
|
this.activityTimer = null;
|
|
1934
1756
|
}
|
|
1935
1757
|
this.pendingTimer.clearTimer();
|
|
1936
|
-
document.removeEventListener('mousemove', this.boundMouseMove);
|
|
1937
|
-
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
1938
|
-
document.removeEventListener('mousedown', this.boundGlobalMouseDown);
|
|
1939
|
-
document.removeEventListener('keydown', this.boundKeyDown);
|
|
1940
|
-
document.removeEventListener('keyup', this.boundKeyUp);
|
|
1941
|
-
window.removeEventListener('blur', this.boundWindowBlur);
|
|
1942
|
-
document.removeEventListener('touchmove', this.boundTouchMove);
|
|
1943
|
-
document.removeEventListener('touchend', this.boundTouchEnd);
|
|
1944
|
-
document.removeEventListener('touchcancel', this.boundTouchCancel);
|
|
1945
1758
|
window.removeEventListener('resize', this.boundWindowResize);
|
|
1946
1759
|
|
|
1947
1760
|
const canvas = this.querySelector('#canvas');
|
|
1948
1761
|
if (canvas) {
|
|
1949
1762
|
canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1950
|
-
canvas.removeEventListener('touchstart', this.boundCanvasTouchStart);
|
|
1951
1763
|
}
|
|
1952
1764
|
|
|
1953
1765
|
const editor = this.querySelector('#editor');
|
|
@@ -1967,33 +1779,14 @@ export class Editor extends RapidElement {
|
|
|
1967
1779
|
}
|
|
1968
1780
|
|
|
1969
1781
|
private setupGlobalEventListeners(): void {
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
document.addEventListener('mousedown', this.boundGlobalMouseDown);
|
|
1973
|
-
document.addEventListener('keydown', this.boundKeyDown);
|
|
1974
|
-
document.addEventListener('keyup', this.boundKeyUp);
|
|
1975
|
-
window.addEventListener('blur', this.boundWindowBlur);
|
|
1976
|
-
document.addEventListener('touchmove', this.boundTouchMove, {
|
|
1977
|
-
passive: false
|
|
1978
|
-
});
|
|
1979
|
-
document.addEventListener('touchend', this.boundTouchEnd);
|
|
1980
|
-
document.addEventListener('touchcancel', this.boundTouchCancel);
|
|
1981
|
-
window.addEventListener('resize', this.boundWindowResize);
|
|
1782
|
+
// Drag/selection listeners managed by DragManager
|
|
1783
|
+
this.dragManager.setupListeners();
|
|
1982
1784
|
|
|
1983
|
-
|
|
1984
|
-
// navigator.maxTouchPoints wasn't detected in firstUpdated.
|
|
1985
|
-
const markTouchOnce = () => {
|
|
1986
|
-
this.markTouchDevice();
|
|
1987
|
-
document.removeEventListener('touchstart', markTouchOnce);
|
|
1988
|
-
};
|
|
1989
|
-
document.addEventListener('touchstart', markTouchOnce);
|
|
1785
|
+
window.addEventListener('resize', this.boundWindowResize);
|
|
1990
1786
|
|
|
1991
1787
|
const canvas = this.querySelector('#canvas');
|
|
1992
1788
|
if (canvas) {
|
|
1993
1789
|
canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1994
|
-
canvas.addEventListener('touchstart', this.boundCanvasTouchStart, {
|
|
1995
|
-
passive: false
|
|
1996
|
-
});
|
|
1997
1790
|
}
|
|
1998
1791
|
|
|
1999
1792
|
const editor = this.querySelector('#editor');
|
|
@@ -2082,208 +1875,12 @@ export class Editor extends RapidElement {
|
|
|
2082
1875
|
});
|
|
2083
1876
|
}
|
|
2084
1877
|
|
|
2085
|
-
private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition {
|
|
2086
|
-
if (type === 'node') {
|
|
2087
|
-
return this.definition._ui.nodes[uuid]?.position;
|
|
2088
|
-
} else {
|
|
2089
|
-
return this.definition._ui.stickies?.[uuid]?.position;
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
private handleMouseDown(event: MouseEvent): void {
|
|
2094
|
-
if (isRightClick(event)) return;
|
|
2095
|
-
|
|
2096
|
-
if (this.isReadOnly()) return;
|
|
2097
|
-
this.blurActiveContentEditable();
|
|
2098
|
-
|
|
2099
|
-
const element = event.currentTarget as HTMLElement;
|
|
2100
|
-
// Only start dragging if clicking on the element itself, not on exits or other interactive elements
|
|
2101
|
-
const target = event.target as HTMLElement;
|
|
2102
|
-
if (
|
|
2103
|
-
target.classList.contains('exit') ||
|
|
2104
|
-
target.closest('.exit') ||
|
|
2105
|
-
target.closest('.linked-name')
|
|
2106
|
-
) {
|
|
2107
|
-
return;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
const uuid = element.getAttribute('uuid');
|
|
2111
|
-
const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
2112
|
-
|
|
2113
|
-
const position = this.getPosition(uuid, type);
|
|
2114
|
-
if (!position) return;
|
|
2115
|
-
|
|
2116
|
-
// If clicking on a non-selected item, clear selection unless Ctrl/Cmd is held
|
|
2117
|
-
if (!this.selectedItems.has(uuid) && !event.ctrlKey && !event.metaKey) {
|
|
2118
|
-
this.selectedItems.clear();
|
|
2119
|
-
// Don't add single items to selection - single clicks just clear existing selection
|
|
2120
|
-
} else if (!this.selectedItems.has(uuid)) {
|
|
2121
|
-
// Add this item to selection only if Ctrl/Cmd is held
|
|
2122
|
-
this.selectedItems.add(uuid);
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
// Always set up drag state regardless of selection status
|
|
2126
|
-
// This allows single nodes to be dragged without being selected
|
|
2127
|
-
this.isMouseDown = true;
|
|
2128
|
-
this.shiftDragCopy = event.shiftKey;
|
|
2129
|
-
this.dragStartPos = { x: event.clientX, y: event.clientY };
|
|
2130
|
-
this.startPos = { left: position.left, top: position.top };
|
|
2131
|
-
this.currentDragItem = {
|
|
2132
|
-
uuid,
|
|
2133
|
-
position,
|
|
2134
|
-
element,
|
|
2135
|
-
type
|
|
2136
|
-
};
|
|
2137
|
-
|
|
2138
|
-
event.preventDefault();
|
|
2139
|
-
event.stopPropagation();
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
/**
|
|
2143
|
-
* Mirror of handleMouseDown for touch devices.
|
|
2144
|
-
* Sets up the same drag state so handleTouchMove/End can drive the drag.
|
|
2145
|
-
*/
|
|
2146
|
-
/* c8 ignore start -- touch-only handlers untestable in headless Chromium */
|
|
2147
|
-
|
|
2148
|
-
/**
|
|
2149
|
-
* Mark the editor as a touch device — adds classes to #canvas and
|
|
2150
|
-
* #editor so touch-specific CSS activates (visible controls,
|
|
2151
|
-
* always-on scrollbars, touch-action: none).
|
|
2152
|
-
*/
|
|
2153
|
-
private markTouchDevice(): void {
|
|
2154
|
-
if (this.isTouchDevice) return;
|
|
2155
|
-
this.isTouchDevice = true;
|
|
2156
|
-
this.querySelector('#canvas')?.classList.add('touch-device');
|
|
2157
|
-
this.querySelector('#editor')?.classList.add('touch-device');
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
private handleItemTouchStart(event: TouchEvent): void {
|
|
2161
|
-
this.markTouchDevice();
|
|
2162
|
-
|
|
2163
|
-
if (this.isReadOnly()) return;
|
|
2164
|
-
this.blurActiveContentEditable();
|
|
2165
|
-
|
|
2166
|
-
const touch = event.touches[0];
|
|
2167
|
-
if (!touch) return;
|
|
2168
|
-
|
|
2169
|
-
const element = event.currentTarget as HTMLElement;
|
|
2170
|
-
const target = event.target as HTMLElement;
|
|
2171
|
-
if (
|
|
2172
|
-
target.classList.contains('exit') ||
|
|
2173
|
-
target.closest('.exit') ||
|
|
2174
|
-
target.closest('.linked-name')
|
|
2175
|
-
) {
|
|
2176
|
-
return;
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
const uuid = element.getAttribute('uuid');
|
|
2180
|
-
const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
2181
|
-
|
|
2182
|
-
const position = this.getPosition(uuid, type);
|
|
2183
|
-
if (!position) return;
|
|
2184
|
-
|
|
2185
|
-
// Touch doesn't support Ctrl/Cmd selection — just clear
|
|
2186
|
-
if (!this.selectedItems.has(uuid)) {
|
|
2187
|
-
this.selectedItems.clear();
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
this.isMouseDown = true;
|
|
2191
|
-
this.dragStartPos = { x: touch.clientX, y: touch.clientY };
|
|
2192
|
-
this.startPos = { left: position.left, top: position.top };
|
|
2193
|
-
this.currentDragItem = {
|
|
2194
|
-
uuid,
|
|
2195
|
-
position,
|
|
2196
|
-
element,
|
|
2197
|
-
type
|
|
2198
|
-
};
|
|
2199
|
-
|
|
2200
|
-
// Don't preventDefault here — allow the threshold check in touchmove
|
|
2201
|
-
// to decide whether this is a drag or a tap
|
|
2202
|
-
event.stopPropagation();
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
|
-
/* c8 ignore stop */
|
|
2206
|
-
|
|
2207
|
-
private handleGlobalMouseDown(event: MouseEvent): void {
|
|
2208
|
-
if (isRightClick(event)) return;
|
|
2209
|
-
|
|
2210
|
-
// Check if the click is within our canvas
|
|
2211
|
-
const canvasRect = this.querySelector('#grid')?.getBoundingClientRect();
|
|
2212
|
-
|
|
2213
|
-
if (!canvasRect) return;
|
|
2214
|
-
|
|
2215
|
-
const isWithinCanvas =
|
|
2216
|
-
event.clientX >= canvasRect.left &&
|
|
2217
|
-
event.clientX <= canvasRect.right &&
|
|
2218
|
-
event.clientY >= canvasRect.top &&
|
|
2219
|
-
event.clientY <= canvasRect.bottom;
|
|
2220
|
-
|
|
2221
|
-
if (!isWithinCanvas) return;
|
|
2222
|
-
|
|
2223
|
-
// Check if we clicked on a draggable item (node or sticky)
|
|
2224
|
-
const target = event.target as HTMLElement;
|
|
2225
|
-
const clickedOnDraggable = target.closest('.draggable');
|
|
2226
|
-
|
|
2227
|
-
if (clickedOnDraggable) {
|
|
2228
|
-
// This is handled by the individual item mousedown handlers
|
|
2229
|
-
return;
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
// We clicked on empty canvas space, start selection
|
|
2233
|
-
this.handleCanvasMouseDown(event);
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
private blurActiveContentEditable(): void {
|
|
2237
|
-
let active: Element | null = document.activeElement;
|
|
2238
|
-
while (active?.shadowRoot?.activeElement) {
|
|
2239
|
-
active = active.shadowRoot.activeElement;
|
|
2240
|
-
}
|
|
2241
|
-
if (
|
|
2242
|
-
active instanceof HTMLElement &&
|
|
2243
|
-
active.getAttribute('contenteditable') === 'true'
|
|
2244
|
-
) {
|
|
2245
|
-
active.blur();
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
private handleCanvasMouseDown(event: MouseEvent): void {
|
|
2250
|
-
if (this.isReadOnly()) return;
|
|
2251
|
-
this.blurActiveContentEditable();
|
|
2252
|
-
|
|
2253
|
-
const target = event.target as HTMLElement;
|
|
2254
|
-
if (target.id === 'canvas' || target.id === 'grid') {
|
|
2255
|
-
// Ignore clicks on exits
|
|
2256
|
-
|
|
2257
|
-
// Start selection box
|
|
2258
|
-
this.canvasMouseDown = true;
|
|
2259
|
-
this.dragStartPos = { x: event.clientX, y: event.clientY };
|
|
2260
|
-
|
|
2261
|
-
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
2262
|
-
if (canvasRect) {
|
|
2263
|
-
// Clear current selection
|
|
2264
|
-
this.selectedItems.clear();
|
|
2265
|
-
|
|
2266
|
-
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
2267
|
-
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
2268
|
-
|
|
2269
|
-
this.selectionBox = {
|
|
2270
|
-
startX: relativeX,
|
|
2271
|
-
startY: relativeY,
|
|
2272
|
-
endX: relativeX,
|
|
2273
|
-
endY: relativeY
|
|
2274
|
-
};
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
event.preventDefault();
|
|
2278
|
-
}
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
1878
|
private openFlowSearch(): void {
|
|
2282
1879
|
if (this.viewingRevision) {
|
|
2283
1880
|
return;
|
|
2284
1881
|
}
|
|
2285
1882
|
|
|
2286
|
-
if (this.isDialogOrMenuOpen()) {
|
|
1883
|
+
if (this.zoomManager.isDialogOrMenuOpen()) {
|
|
2287
1884
|
return;
|
|
2288
1885
|
}
|
|
2289
1886
|
|
|
@@ -2295,7 +1892,7 @@ export class Editor extends RapidElement {
|
|
|
2295
1892
|
search.definition = this.definition;
|
|
2296
1893
|
search.languageCode = this.languageCode || '';
|
|
2297
1894
|
search.scope = this.showMessageTable ? 'table' : 'flow';
|
|
2298
|
-
search.includeCategories =
|
|
1895
|
+
search.includeCategories = false;
|
|
2299
1896
|
search.show();
|
|
2300
1897
|
}
|
|
2301
1898
|
|
|
@@ -2306,49 +1903,13 @@ export class Editor extends RapidElement {
|
|
|
2306
1903
|
}
|
|
2307
1904
|
}
|
|
2308
1905
|
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
const hint = this.querySelector('#drag-hint') as HTMLElement;
|
|
2312
|
-
if (!hint) return;
|
|
2313
|
-
this.dragHintTimer = setTimeout(() => {
|
|
2314
|
-
hint.classList.add('visible');
|
|
2315
|
-
this.dragHintTimer = null;
|
|
2316
|
-
}, 600);
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
private hideDragHint(): void {
|
|
2320
|
-
if (this.dragHintTimer) {
|
|
2321
|
-
clearTimeout(this.dragHintTimer);
|
|
2322
|
-
this.dragHintTimer = null;
|
|
2323
|
-
}
|
|
2324
|
-
const hint = this.querySelector('#drag-hint') as HTMLElement;
|
|
2325
|
-
if (hint) {
|
|
2326
|
-
hint.classList.remove('visible');
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
private handleKeyDown(event: KeyboardEvent): void {
|
|
1906
|
+
// Called by DragManager for non-drag keyboard handling
|
|
1907
|
+
public handleKeyDown(event: KeyboardEvent): void {
|
|
2331
1908
|
if (event.key === 'Shift') {
|
|
2332
|
-
this.querySelector('#canvas')?.classList.add('shift-held');
|
|
2333
|
-
|
|
2334
|
-
// Toggle to copy mode mid-drag (nodes)
|
|
2335
|
-
if (this.isDragging && !this.currentDragIsCopy) {
|
|
2336
|
-
this.hideDragHint();
|
|
2337
|
-
this.performShiftDragCopy();
|
|
2338
|
-
// Clone elements aren't in the DOM until Lit re-renders;
|
|
2339
|
-
// schedule position update after the next frame.
|
|
2340
|
-
requestAnimationFrame(() => {
|
|
2341
|
-
this.markCopyElements();
|
|
2342
|
-
this.updateDragPositions();
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
1909
|
// Toggle to copy mode mid-drag (actions)
|
|
2347
1910
|
if (this.isActionExternalDrag && !this.actionDragIsCopy) {
|
|
2348
1911
|
this.actionDragIsCopy = true;
|
|
2349
|
-
this.hideDragHint();
|
|
2350
1912
|
this.showActionOriginal(true);
|
|
2351
|
-
// If this is a last-action drag, now show the canvas preview
|
|
2352
1913
|
if (this.actionDragLastDetail?.isLastAction) {
|
|
2353
1914
|
this.reprocessActionDrag();
|
|
2354
1915
|
}
|
|
@@ -2368,7 +1929,6 @@ export class Editor extends RapidElement {
|
|
|
2368
1929
|
if (search?.open) return;
|
|
2369
1930
|
|
|
2370
1931
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
2371
|
-
// If delete confirmation dialog is already showing, confirm it
|
|
2372
1932
|
if (this.deleteDialog?.open) {
|
|
2373
1933
|
this.deleteSelectedItems();
|
|
2374
1934
|
this.deleteDialog.open = false;
|
|
@@ -2385,22 +1945,12 @@ export class Editor extends RapidElement {
|
|
|
2385
1945
|
}
|
|
2386
1946
|
}
|
|
2387
1947
|
|
|
2388
|
-
|
|
1948
|
+
// Called by DragManager for action drag shift-copy
|
|
1949
|
+
public handleKeyUp(event: KeyboardEvent): void {
|
|
2389
1950
|
if (event.key === 'Shift') {
|
|
2390
|
-
this.querySelector('#canvas')?.classList.remove('shift-held');
|
|
2391
|
-
|
|
2392
|
-
// Toggle back to move mode mid-drag (nodes)
|
|
2393
|
-
if (this.isDragging && this.currentDragIsCopy) {
|
|
2394
|
-
this.revertShiftDragCopy();
|
|
2395
|
-
requestAnimationFrame(() => this.updateDragPositions());
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
// Toggle back to move mode mid-drag (actions)
|
|
2399
1951
|
if (this.isActionExternalDrag && this.actionDragIsCopy) {
|
|
2400
1952
|
this.actionDragIsCopy = false;
|
|
2401
|
-
this.showDragHint();
|
|
2402
1953
|
this.showActionOriginal(false);
|
|
2403
|
-
// If this is a last-action drag, hide the canvas preview again
|
|
2404
1954
|
if (this.actionDragLastDetail?.isLastAction) {
|
|
2405
1955
|
this.reprocessActionDrag();
|
|
2406
1956
|
}
|
|
@@ -2409,16 +1959,8 @@ export class Editor extends RapidElement {
|
|
|
2409
1959
|
}
|
|
2410
1960
|
}
|
|
2411
1961
|
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
// Revert copy mode if blur happens mid-drag (keyup may never fire)
|
|
2416
|
-
if (this.isDragging && this.currentDragIsCopy) {
|
|
2417
|
-
this.revertShiftDragCopy();
|
|
2418
|
-
requestAnimationFrame(() => this.updateDragPositions());
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
// Revert action copy mode on blur
|
|
1962
|
+
// Called by DragManager on window blur
|
|
1963
|
+
public handleWindowBlur(): void {
|
|
2422
1964
|
if (this.isActionExternalDrag && this.actionDragIsCopy) {
|
|
2423
1965
|
this.actionDragIsCopy = false;
|
|
2424
1966
|
this.showActionOriginal(false);
|
|
@@ -2433,7 +1975,7 @@ export class Editor extends RapidElement {
|
|
|
2433
1975
|
|
|
2434
1976
|
static MAX_FLOW_SETTINGS = 50;
|
|
2435
1977
|
|
|
2436
|
-
|
|
1978
|
+
public getFlowSettings(): Record<string, any> {
|
|
2437
1979
|
try {
|
|
2438
1980
|
return JSON.parse(getCookie('flow-settings') || '{}');
|
|
2439
1981
|
} catch {
|
|
@@ -2441,7 +1983,7 @@ export class Editor extends RapidElement {
|
|
|
2441
1983
|
}
|
|
2442
1984
|
}
|
|
2443
1985
|
|
|
2444
|
-
|
|
1986
|
+
public saveFlowSetting(key: string, value: any): void {
|
|
2445
1987
|
if (!this.flow) return;
|
|
2446
1988
|
const settings = this.getFlowSettings();
|
|
2447
1989
|
|
|
@@ -2463,563 +2005,81 @@ export class Editor extends RapidElement {
|
|
|
2463
2005
|
setCookie('flow-settings', JSON.stringify(settings));
|
|
2464
2006
|
}
|
|
2465
2007
|
|
|
2466
|
-
|
|
2008
|
+
public getFlowSetting<T>(key: string): T | undefined {
|
|
2467
2009
|
if (!this.flow) return undefined;
|
|
2468
2010
|
return this.getFlowSettings()[this.flow]?.[key];
|
|
2469
2011
|
}
|
|
2470
2012
|
|
|
2471
|
-
|
|
2013
|
+
private showDeleteConfirmation(): void {
|
|
2014
|
+
const itemCount = this.selectedItems.size;
|
|
2015
|
+
const itemType = itemCount === 1 ? 'item' : 'items';
|
|
2472
2016
|
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
): void {
|
|
2477
|
-
const clamped = Math.max(
|
|
2478
|
-
0.3,
|
|
2479
|
-
Math.min(1.0, Math.round(newZoom * 100) / 100)
|
|
2480
|
-
);
|
|
2481
|
-
if (clamped === this.zoom) return;
|
|
2017
|
+
// Create and show confirmation dialog
|
|
2018
|
+
// Don't open a second dialog if one is already showing
|
|
2019
|
+
if (this.deleteDialog?.open) return;
|
|
2482
2020
|
|
|
2483
|
-
const
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
if (editor && center) {
|
|
2491
|
-
const editorRect = editor.getBoundingClientRect();
|
|
2492
|
-
const ox = center.clientX - editorRect.left;
|
|
2493
|
-
const oy = center.clientY - editorRect.top;
|
|
2494
|
-
// Canvas point under cursor at old zoom
|
|
2495
|
-
const cx = (editor.scrollLeft + ox) / oldZoom;
|
|
2496
|
-
const cy = (editor.scrollTop + oy) / oldZoom;
|
|
2021
|
+
const dialog = document.createElement('temba-dialog') as Dialog;
|
|
2022
|
+
dialog.header = 'Delete Items';
|
|
2023
|
+
dialog.primaryButtonName = 'Delete';
|
|
2024
|
+
dialog.cancelButtonName = 'Cancel';
|
|
2025
|
+
dialog.destructive = true;
|
|
2026
|
+
dialog.innerHTML = `<div style="padding: 20px;">Are you sure you want to delete ${itemCount} ${itemType}?</div>`;
|
|
2497
2027
|
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
requestAnimationFrame(() => this.plumber.repaintEverything());
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2028
|
+
dialog.addEventListener('temba-button-clicked', (event: any) => {
|
|
2029
|
+
if (event.detail.button.name === 'Delete') {
|
|
2030
|
+
this.deleteSelectedItems();
|
|
2031
|
+
dialog.open = false;
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2507
2034
|
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2035
|
+
// Add to document and show
|
|
2036
|
+
document.body.appendChild(dialog);
|
|
2037
|
+
dialog.open = true;
|
|
2038
|
+
this.deleteDialog = dialog;
|
|
2511
2039
|
|
|
2512
|
-
|
|
2513
|
-
|
|
2040
|
+
// Clean up dialog when closed
|
|
2041
|
+
dialog.addEventListener('temba-dialog-hidden', () => {
|
|
2042
|
+
document.body.removeChild(dialog);
|
|
2043
|
+
this.deleteDialog = null;
|
|
2044
|
+
});
|
|
2514
2045
|
}
|
|
2515
2046
|
|
|
2516
|
-
private
|
|
2047
|
+
private performReflow(): void {
|
|
2517
2048
|
if (!this.definition || this.definition.nodes.length === 0) return;
|
|
2518
2049
|
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
// Calculate bounding box of all content in canvas coordinates
|
|
2523
|
-
let minX = Infinity;
|
|
2524
|
-
let minY = Infinity;
|
|
2525
|
-
let maxX = -Infinity;
|
|
2526
|
-
let maxY = -Infinity;
|
|
2527
|
-
|
|
2528
|
-
this.definition.nodes.forEach((node) => {
|
|
2529
|
-
const ui = this.definition._ui?.nodes[node.uuid];
|
|
2530
|
-
if (!ui?.position) return;
|
|
2531
|
-
const el = this.querySelector(`[id="${node.uuid}"]`) as HTMLElement;
|
|
2532
|
-
if (!el) return;
|
|
2533
|
-
const w = el.offsetWidth;
|
|
2534
|
-
const h = el.offsetHeight;
|
|
2535
|
-
minX = Math.min(minX, ui.position.left);
|
|
2536
|
-
minY = Math.min(minY, ui.position.top);
|
|
2537
|
-
maxX = Math.max(maxX, ui.position.left + w);
|
|
2538
|
-
maxY = Math.max(maxY, ui.position.top + h);
|
|
2539
|
-
});
|
|
2050
|
+
// Save current positions for discard (only on first pending operation)
|
|
2051
|
+
this.capturePositionsOnce();
|
|
2540
2052
|
|
|
2541
2053
|
const stickies = this.definition._ui?.stickies || {};
|
|
2542
|
-
Object.entries(stickies).forEach(([uuid, sticky]) => {
|
|
2543
|
-
if (!sticky.position) return;
|
|
2544
|
-
const el = this.querySelector(
|
|
2545
|
-
`temba-sticky-note[uuid="${uuid}"]`
|
|
2546
|
-
) as HTMLElement;
|
|
2547
|
-
if (!el) return;
|
|
2548
|
-
const w = el.offsetWidth;
|
|
2549
|
-
const h = el.offsetHeight;
|
|
2550
|
-
minX = Math.min(minX, sticky.position.left);
|
|
2551
|
-
minY = Math.min(minY, sticky.position.top);
|
|
2552
|
-
maxX = Math.max(maxX, sticky.position.left + w);
|
|
2553
|
-
maxY = Math.max(maxY, sticky.position.top + h);
|
|
2554
|
-
});
|
|
2555
2054
|
|
|
2556
|
-
|
|
2055
|
+
// Save old node positions before reflow for sticky proximity calculation
|
|
2056
|
+
const oldNodePositions: Record<string, FlowPosition> = {};
|
|
2057
|
+
for (const node of this.definition.nodes) {
|
|
2058
|
+
const ui = this.definition._ui?.nodes[node.uuid];
|
|
2059
|
+
if (ui?.position) {
|
|
2060
|
+
oldNodePositions[node.uuid] = { ...ui.position };
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2557
2063
|
|
|
2558
|
-
|
|
2559
|
-
const
|
|
2560
|
-
const padding = 40;
|
|
2064
|
+
// Identify start node (first in sorted array)
|
|
2065
|
+
const startNodeUuid = this.definition.nodes[0].uuid;
|
|
2561
2066
|
|
|
2562
|
-
|
|
2563
|
-
const
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
const centerY = (minY + maxY) / 2 + 40;
|
|
2579
|
-
|
|
2580
|
-
requestAnimationFrame(() => {
|
|
2581
|
-
editor.scrollLeft = centerX * fitZoom - editor.clientWidth / 2;
|
|
2582
|
-
editor.scrollTop = centerY * fitZoom - editor.clientHeight / 2;
|
|
2583
|
-
this.plumber.repaintEverything();
|
|
2584
|
-
});
|
|
2585
|
-
}
|
|
2586
|
-
|
|
2587
|
-
private zoomToFull(): void {
|
|
2588
|
-
this.setZoom(1.0);
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
/** Adjust floating tab positioning relative to toolbar and editor scrollbar */
|
|
2592
|
-
private updateZoomControlPositioning(): void {
|
|
2593
|
-
requestAnimationFrame(() => {
|
|
2594
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2595
|
-
if (editor) {
|
|
2596
|
-
const scrollbarWidth = Math.max(
|
|
2597
|
-
editor.offsetWidth - editor.clientWidth,
|
|
2598
|
-
0
|
|
2599
|
-
);
|
|
2600
|
-
// Keep floating tabs just left of the vertical scrollbar.
|
|
2601
|
-
document.documentElement.style.setProperty(
|
|
2602
|
-
'--floating-tab-clip',
|
|
2603
|
-
`${scrollbarWidth}px`
|
|
2604
|
-
);
|
|
2605
|
-
}
|
|
2606
|
-
|
|
2607
|
-
const toolbar = this.querySelector('.editor-toolbar') as HTMLElement;
|
|
2608
|
-
if (toolbar) {
|
|
2609
|
-
const rect = toolbar.getBoundingClientRect();
|
|
2610
|
-
FloatingTab.START_TOP = rect.bottom + 20;
|
|
2611
|
-
FloatingTab.updateAllPositions();
|
|
2612
|
-
}
|
|
2613
|
-
});
|
|
2614
|
-
}
|
|
2615
|
-
|
|
2616
|
-
private handleWheel(event: WheelEvent): void {
|
|
2617
|
-
if (!event.ctrlKey && !event.metaKey) return;
|
|
2618
|
-
event.preventDefault();
|
|
2619
|
-
|
|
2620
|
-
const delta = event.deltaY > 0 ? -0.05 : 0.05;
|
|
2621
|
-
this.setZoom(this.zoom + delta, {
|
|
2622
|
-
clientX: event.clientX,
|
|
2623
|
-
clientY: event.clientY
|
|
2624
|
-
});
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
// --- Loupe magnifier ---
|
|
2628
|
-
|
|
2629
|
-
private static readonly LOUPE_DIAMETER = 280;
|
|
2630
|
-
|
|
2631
|
-
private readonly boundLoupeMouseMove = this.handleLoupeMouseMove.bind(this);
|
|
2632
|
-
private readonly boundLoupeMouseDown = this.handleLoupeMouseDown.bind(this);
|
|
2633
|
-
private readonly boundLoupeMouseUp = this.handleLoupeMouseUp.bind(this);
|
|
2634
|
-
private readonly boundLoupeKeyDown = this.handleLoupeKeyDown.bind(this);
|
|
2635
|
-
private readonly boundLoupeKeyUp = this.handleLoupeKeyUp.bind(this);
|
|
2636
|
-
private loupeKeyHeld = false;
|
|
2637
|
-
private loupeMouseIsDown = false;
|
|
2638
|
-
private loupeLastMouse: { clientX: number; clientY: number } | null = null;
|
|
2639
|
-
|
|
2640
|
-
private initLoupe(): void {
|
|
2641
|
-
document.addEventListener('mousemove', this.boundLoupeMouseMove);
|
|
2642
|
-
document.addEventListener('keydown', this.boundLoupeKeyDown);
|
|
2643
|
-
document.addEventListener('keyup', this.boundLoupeKeyUp);
|
|
2644
|
-
document.addEventListener('mouseup', this.boundLoupeMouseUp);
|
|
2645
|
-
// Capture-phase listener catches all mousedowns (including those where
|
|
2646
|
-
// Plumber calls stopPropagation, e.g. exits and connection re-routing)
|
|
2647
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2648
|
-
if (editor) {
|
|
2649
|
-
editor.addEventListener('mousedown', this.boundLoupeMouseDown, true);
|
|
2650
|
-
}
|
|
2651
|
-
}
|
|
2652
|
-
|
|
2653
|
-
private teardownLoupe(): void {
|
|
2654
|
-
document.removeEventListener('mousemove', this.boundLoupeMouseMove);
|
|
2655
|
-
document.removeEventListener('keydown', this.boundLoupeKeyDown);
|
|
2656
|
-
document.removeEventListener('keyup', this.boundLoupeKeyUp);
|
|
2657
|
-
document.removeEventListener('mouseup', this.boundLoupeMouseUp);
|
|
2658
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2659
|
-
if (editor) {
|
|
2660
|
-
editor.removeEventListener('mousedown', this.boundLoupeMouseDown, true);
|
|
2661
|
-
}
|
|
2662
|
-
this.hideLoupe();
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
private handleLoupeKeyDown(event: KeyboardEvent): void {
|
|
2666
|
-
// Cmd+Ctrl+A (Mac) / Ctrl+Meta+A (Windows)
|
|
2667
|
-
if (event.key.toLowerCase() !== 'a') return;
|
|
2668
|
-
if (event.metaKey && event.ctrlKey) {
|
|
2669
|
-
event.preventDefault();
|
|
2670
|
-
this.loupeKeyHeld = true;
|
|
2671
|
-
// Show loupe immediately at last known mouse position
|
|
2672
|
-
if (this.loupeLastMouse) {
|
|
2673
|
-
this.handleLoupeMouseMove(this.loupeLastMouse as MouseEvent);
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
}
|
|
2677
|
-
|
|
2678
|
-
private handleLoupeKeyUp(event: KeyboardEvent): void {
|
|
2679
|
-
if (!this.loupeKeyHeld) return;
|
|
2680
|
-
// Hide when any modifier is released
|
|
2681
|
-
if (event.key === 'a' || event.key === 'Meta' || event.key === 'Control') {
|
|
2682
|
-
this.loupeKeyHeld = false;
|
|
2683
|
-
this.hideLoupe();
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
private handleLoupeMouseDown(): void {
|
|
2688
|
-
this.loupeMouseIsDown = true;
|
|
2689
|
-
this.hideLoupe();
|
|
2690
|
-
}
|
|
2691
|
-
|
|
2692
|
-
private handleLoupeMouseUp(): void {
|
|
2693
|
-
this.loupeMouseIsDown = false;
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
private handleLoupeMouseMove(event: MouseEvent): void {
|
|
2697
|
-
this.loupeLastMouse = { clientX: event.clientX, clientY: event.clientY };
|
|
2698
|
-
|
|
2699
|
-
// Require Cmd+Ctrl+A held, hide while mouse is down, during interactions, or with dialogs open
|
|
2700
|
-
if (
|
|
2701
|
-
!this.loupeKeyHeld ||
|
|
2702
|
-
this.loupeMouseIsDown ||
|
|
2703
|
-
this.isDragging ||
|
|
2704
|
-
this.isSelecting ||
|
|
2705
|
-
this.plumber?.connectionDragging ||
|
|
2706
|
-
this.isDialogOrMenuOpen()
|
|
2707
|
-
) {
|
|
2708
|
-
this.hideLoupe();
|
|
2709
|
-
return;
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
// Check if cursor is within the editor bounds
|
|
2713
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2714
|
-
if (!editor) return;
|
|
2715
|
-
const rect = editor.getBoundingClientRect();
|
|
2716
|
-
if (
|
|
2717
|
-
event.clientX < rect.left ||
|
|
2718
|
-
event.clientX > rect.right ||
|
|
2719
|
-
event.clientY < rect.top ||
|
|
2720
|
-
event.clientY > rect.bottom
|
|
2721
|
-
) {
|
|
2722
|
-
this.hideLoupe();
|
|
2723
|
-
return;
|
|
2724
|
-
}
|
|
2725
|
-
|
|
2726
|
-
if (this.loupeRAF) cancelAnimationFrame(this.loupeRAF);
|
|
2727
|
-
this.loupeRAF = requestAnimationFrame(() => {
|
|
2728
|
-
this.updateLoupe(event.clientX, event.clientY);
|
|
2729
|
-
});
|
|
2730
|
-
}
|
|
2731
|
-
|
|
2732
|
-
private isDialogOrMenuOpen(): boolean {
|
|
2733
|
-
if (this.editingNode || this.editingAction) return true;
|
|
2734
|
-
if (this.deleteDialog?.open) return true;
|
|
2735
|
-
const canvasMenu = this.querySelector('temba-canvas-menu') as any;
|
|
2736
|
-
if (canvasMenu?.open) return true;
|
|
2737
|
-
return false;
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
private hideLoupe(): void {
|
|
2741
|
-
if (this.loupeEl) {
|
|
2742
|
-
this.loupeEl.classList.remove('visible');
|
|
2743
|
-
}
|
|
2744
|
-
this.restoreTitles();
|
|
2745
|
-
if (this.loupeClone) {
|
|
2746
|
-
this.loupeClone.remove();
|
|
2747
|
-
this.loupeClone = null;
|
|
2748
|
-
}
|
|
2749
|
-
if (this.loupeRAF) {
|
|
2750
|
-
cancelAnimationFrame(this.loupeRAF);
|
|
2751
|
-
this.loupeRAF = null;
|
|
2752
|
-
}
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
private suppressTitles(): void {
|
|
2756
|
-
this.hiddenTitles = [];
|
|
2757
|
-
const canvas = this.querySelector('#canvas');
|
|
2758
|
-
if (!canvas) return;
|
|
2759
|
-
for (const el of canvas.querySelectorAll('[title]')) {
|
|
2760
|
-
this.hiddenTitles.push({ el, title: el.getAttribute('title')! });
|
|
2761
|
-
el.removeAttribute('title');
|
|
2762
|
-
}
|
|
2763
|
-
// Also check shadow DOMs of canvas nodes and sticky notes
|
|
2764
|
-
for (const node of canvas.querySelectorAll(
|
|
2765
|
-
'temba-canvas-node, temba-sticky-note'
|
|
2766
|
-
)) {
|
|
2767
|
-
if (node.shadowRoot) {
|
|
2768
|
-
for (const el of node.shadowRoot.querySelectorAll('[title]')) {
|
|
2769
|
-
this.hiddenTitles.push({ el, title: el.getAttribute('title')! });
|
|
2770
|
-
el.removeAttribute('title');
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2774
|
-
}
|
|
2775
|
-
|
|
2776
|
-
private restoreTitles(): void {
|
|
2777
|
-
for (const { el, title } of this.hiddenTitles) {
|
|
2778
|
-
el.setAttribute('title', title);
|
|
2779
|
-
}
|
|
2780
|
-
this.hiddenTitles = [];
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
private loupeCloneTime = 0;
|
|
2784
|
-
private loupeClone: HTMLElement | null = null;
|
|
2785
|
-
private loupeCursorCanvas: { x: number; y: number } = { x: 0, y: 0 };
|
|
2786
|
-
private static readonly LOUPE_CLONE_INTERVAL = 200;
|
|
2787
|
-
|
|
2788
|
-
private rebuildLoupeClone(
|
|
2789
|
-
canvas: HTMLElement,
|
|
2790
|
-
canvasX: number,
|
|
2791
|
-
canvasY: number,
|
|
2792
|
-
visibleRadius: number
|
|
2793
|
-
): void {
|
|
2794
|
-
const contentEl = this.loupeContentEl;
|
|
2795
|
-
if (!contentEl) return;
|
|
2796
|
-
|
|
2797
|
-
if (this.loupeClone) {
|
|
2798
|
-
this.loupeClone.remove();
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
const clone = document.createElement('div');
|
|
2802
|
-
clone.className = 'loupe-clone';
|
|
2803
|
-
clone.style.width = `${canvas.scrollWidth}px`;
|
|
2804
|
-
clone.style.height = `${canvas.scrollHeight}px`;
|
|
2805
|
-
|
|
2806
|
-
const pad = 50; // extra padding for partially visible elements
|
|
2807
|
-
|
|
2808
|
-
// Clone only nearby nodes (light DOM — innerHTML captures rendered content)
|
|
2809
|
-
const nodeEls = canvas.querySelectorAll('[data-node-uuid]');
|
|
2810
|
-
for (const el of nodeEls) {
|
|
2811
|
-
const htmlEl = el as HTMLElement;
|
|
2812
|
-
const left = parseFloat(htmlEl.style.left) || 0;
|
|
2813
|
-
const top = parseFloat(htmlEl.style.top) || 0;
|
|
2814
|
-
const w = htmlEl.offsetWidth;
|
|
2815
|
-
const h = htmlEl.offsetHeight;
|
|
2816
|
-
|
|
2817
|
-
// Bounding-box vs visible circle check
|
|
2818
|
-
if (
|
|
2819
|
-
left + w < canvasX - visibleRadius - pad ||
|
|
2820
|
-
left > canvasX + visibleRadius + pad ||
|
|
2821
|
-
top + h < canvasY - visibleRadius - pad ||
|
|
2822
|
-
top > canvasY + visibleRadius + pad
|
|
2823
|
-
)
|
|
2824
|
-
continue;
|
|
2825
|
-
|
|
2826
|
-
// Wrap innerHTML in a plain div to avoid custom element upgrade
|
|
2827
|
-
const div = document.createElement('div');
|
|
2828
|
-
div.className = htmlEl.className;
|
|
2829
|
-
div.style.cssText = htmlEl.style.cssText;
|
|
2830
|
-
div.innerHTML = htmlEl.innerHTML;
|
|
2831
|
-
clone.appendChild(div);
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
// Clone SVG connections (standard elements, no upgrade issue)
|
|
2835
|
-
const svgs = canvas.querySelectorAll('svg.plumb-connector');
|
|
2836
|
-
for (const svg of svgs) {
|
|
2837
|
-
clone.appendChild(svg.cloneNode(true));
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
// Clone activity overlays
|
|
2841
|
-
const overlays = canvas.querySelectorAll('.activity-overlay');
|
|
2842
|
-
for (const overlay of overlays) {
|
|
2843
|
-
clone.appendChild(overlay.cloneNode(true));
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
// Clone sticky notes from their shadow DOM
|
|
2847
|
-
const stickyEls = canvas.querySelectorAll('temba-sticky-note');
|
|
2848
|
-
for (const el of stickyEls) {
|
|
2849
|
-
const stickyEl = el as HTMLElement;
|
|
2850
|
-
const sw = stickyEl.offsetWidth;
|
|
2851
|
-
const sh = stickyEl.offsetHeight;
|
|
2852
|
-
const left = parseFloat(stickyEl.style.left) || 0;
|
|
2853
|
-
const top = parseFloat(stickyEl.style.top) || 0;
|
|
2854
|
-
|
|
2855
|
-
if (
|
|
2856
|
-
left + sw < canvasX - visibleRadius - pad ||
|
|
2857
|
-
left > canvasX + visibleRadius + pad ||
|
|
2858
|
-
top + sh < canvasY - visibleRadius - pad ||
|
|
2859
|
-
top > canvasY + visibleRadius + pad
|
|
2860
|
-
)
|
|
2861
|
-
continue;
|
|
2862
|
-
|
|
2863
|
-
if (!stickyEl.shadowRoot) continue;
|
|
2864
|
-
|
|
2865
|
-
const div = document.createElement('div');
|
|
2866
|
-
div.className = stickyEl.className;
|
|
2867
|
-
div.style.cssText = stickyEl.style.cssText;
|
|
2868
|
-
// Extract adopted stylesheets from the shadow root (Lit uses these
|
|
2869
|
-
// instead of inline <style> tags), scoping all rules under .loupe-sticky
|
|
2870
|
-
// to prevent them from leaking into the light DOM
|
|
2871
|
-
div.classList.add('loupe-sticky');
|
|
2872
|
-
const sheets = stickyEl.shadowRoot.adoptedStyleSheets;
|
|
2873
|
-
let cssText = '';
|
|
2874
|
-
for (const sheet of sheets) {
|
|
2875
|
-
for (const rule of sheet.cssRules) {
|
|
2876
|
-
const ruleText = rule.cssText;
|
|
2877
|
-
if (ruleText.startsWith(':host')) {
|
|
2878
|
-
cssText += ruleText.replace(/:host/g, '.loupe-sticky') + '\n';
|
|
2879
|
-
} else {
|
|
2880
|
-
// Scope non-:host rules under .loupe-sticky
|
|
2881
|
-
const braceIdx = ruleText.indexOf('{');
|
|
2882
|
-
if (braceIdx !== -1) {
|
|
2883
|
-
const selector = ruleText.substring(0, braceIdx).trim();
|
|
2884
|
-
const body = ruleText.substring(braceIdx);
|
|
2885
|
-
cssText += `.loupe-sticky ${selector} ${body}\n`;
|
|
2886
|
-
}
|
|
2887
|
-
}
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
2890
|
-
div.innerHTML =
|
|
2891
|
-
`<style>${cssText}</style>` + stickyEl.shadowRoot.innerHTML;
|
|
2892
|
-
clone.appendChild(div);
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
contentEl.appendChild(clone);
|
|
2896
|
-
this.loupeClone = clone;
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
private updateLoupe(clientX: number, clientY: number): void {
|
|
2900
|
-
const loupeEl = this.loupeEl;
|
|
2901
|
-
const contentEl = this.loupeContentEl;
|
|
2902
|
-
if (!loupeEl || !contentEl || !this.definition) return;
|
|
2903
|
-
|
|
2904
|
-
const canvas = this.querySelector('#canvas') as HTMLElement;
|
|
2905
|
-
if (!canvas) return;
|
|
2906
|
-
const canvasRect = canvas.getBoundingClientRect();
|
|
2907
|
-
|
|
2908
|
-
// Canvas coordinates under cursor
|
|
2909
|
-
const canvasX = (clientX - canvasRect.left) / this.zoom;
|
|
2910
|
-
const canvasY = (clientY - canvasRect.top) / this.zoom;
|
|
2911
|
-
|
|
2912
|
-
const D = Editor.LOUPE_DIAMETER;
|
|
2913
|
-
const R = D / 2;
|
|
2914
|
-
// Show content at a fixed comfortable scale inside the loupe
|
|
2915
|
-
const loupeScale = Math.min(1.5, this.zoom * 2.5);
|
|
2916
|
-
const visibleRadius = R / loupeScale;
|
|
2917
|
-
|
|
2918
|
-
// Position loupe at cursor
|
|
2919
|
-
loupeEl.style.left = `${clientX}px`;
|
|
2920
|
-
loupeEl.style.top = `${clientY}px`;
|
|
2921
|
-
loupeEl.classList.add('visible');
|
|
2922
|
-
if (this.hiddenTitles.length === 0) {
|
|
2923
|
-
this.suppressTitles();
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
// Grid background
|
|
2927
|
-
const bgSize = 20 * loupeScale;
|
|
2928
|
-
contentEl.style.backgroundSize = `${bgSize}px ${bgSize}px`;
|
|
2929
|
-
contentEl.style.backgroundPosition = `${R - canvasX * loupeScale}px ${R - canvasY * loupeScale}px`;
|
|
2930
|
-
|
|
2931
|
-
// Rebuild clone periodically or when cursor has moved significantly
|
|
2932
|
-
const now = performance.now();
|
|
2933
|
-
const dx = canvasX - this.loupeCursorCanvas.x;
|
|
2934
|
-
const dy = canvasY - this.loupeCursorCanvas.y;
|
|
2935
|
-
const moved =
|
|
2936
|
-
Math.abs(dx) > visibleRadius * 0.5 || Math.abs(dy) > visibleRadius * 0.5;
|
|
2937
|
-
|
|
2938
|
-
if (
|
|
2939
|
-
!this.loupeClone ||
|
|
2940
|
-
(now - this.loupeCloneTime > Editor.LOUPE_CLONE_INTERVAL && moved)
|
|
2941
|
-
) {
|
|
2942
|
-
this.rebuildLoupeClone(canvas, canvasX, canvasY, visibleRadius);
|
|
2943
|
-
this.loupeCloneTime = now;
|
|
2944
|
-
this.loupeCursorCanvas = { x: canvasX, y: canvasY };
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
|
-
// Position the clone so the canvas point under the cursor is at the loupe center
|
|
2948
|
-
if (this.loupeClone) {
|
|
2949
|
-
this.loupeClone.style.transform = `translate(${R - canvasX * loupeScale}px, ${R - canvasY * loupeScale}px) scale(${loupeScale})`;
|
|
2950
|
-
}
|
|
2951
|
-
}
|
|
2952
|
-
|
|
2953
|
-
private showDeleteConfirmation(): void {
|
|
2954
|
-
const itemCount = this.selectedItems.size;
|
|
2955
|
-
const itemType = itemCount === 1 ? 'item' : 'items';
|
|
2956
|
-
|
|
2957
|
-
// Create and show confirmation dialog
|
|
2958
|
-
// Don't open a second dialog if one is already showing
|
|
2959
|
-
if (this.deleteDialog?.open) return;
|
|
2960
|
-
|
|
2961
|
-
const dialog = document.createElement('temba-dialog') as Dialog;
|
|
2962
|
-
dialog.header = 'Delete Items';
|
|
2963
|
-
dialog.primaryButtonName = 'Delete';
|
|
2964
|
-
dialog.cancelButtonName = 'Cancel';
|
|
2965
|
-
dialog.destructive = true;
|
|
2966
|
-
dialog.innerHTML = `<div style="padding: 20px;">Are you sure you want to delete ${itemCount} ${itemType}?</div>`;
|
|
2967
|
-
|
|
2968
|
-
dialog.addEventListener('temba-button-clicked', (event: any) => {
|
|
2969
|
-
if (event.detail.button.name === 'Delete') {
|
|
2970
|
-
this.deleteSelectedItems();
|
|
2971
|
-
dialog.open = false;
|
|
2972
|
-
}
|
|
2973
|
-
});
|
|
2974
|
-
|
|
2975
|
-
// Add to document and show
|
|
2976
|
-
document.body.appendChild(dialog);
|
|
2977
|
-
dialog.open = true;
|
|
2978
|
-
this.deleteDialog = dialog;
|
|
2979
|
-
|
|
2980
|
-
// Clean up dialog when closed
|
|
2981
|
-
dialog.addEventListener('temba-dialog-hidden', () => {
|
|
2982
|
-
document.body.removeChild(dialog);
|
|
2983
|
-
this.deleteDialog = null;
|
|
2984
|
-
});
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
private performReflow(): void {
|
|
2988
|
-
if (!this.definition || this.definition.nodes.length === 0) return;
|
|
2989
|
-
|
|
2990
|
-
// Save current positions for discard (only on first pending operation)
|
|
2991
|
-
this.capturePositionsOnce();
|
|
2992
|
-
|
|
2993
|
-
const stickies = this.definition._ui?.stickies || {};
|
|
2994
|
-
|
|
2995
|
-
// Save old node positions before reflow for sticky proximity calculation
|
|
2996
|
-
const oldNodePositions: Record<string, FlowPosition> = {};
|
|
2997
|
-
for (const node of this.definition.nodes) {
|
|
2998
|
-
const ui = this.definition._ui?.nodes[node.uuid];
|
|
2999
|
-
if (ui?.position) {
|
|
3000
|
-
oldNodePositions[node.uuid] = { ...ui.position };
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// Identify start node (first in sorted array)
|
|
3005
|
-
const startNodeUuid = this.definition.nodes[0].uuid;
|
|
3006
|
-
|
|
3007
|
-
// Gather node sizes from DOM
|
|
3008
|
-
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
3009
|
-
const getNodeSize = (uuid: string): { width: number; height: number } => {
|
|
3010
|
-
const element = this.querySelector(`[id="${uuid}"]`) as HTMLElement;
|
|
3011
|
-
if (element) {
|
|
3012
|
-
const size = {
|
|
3013
|
-
width: element.offsetWidth,
|
|
3014
|
-
height: element.offsetHeight
|
|
3015
|
-
};
|
|
3016
|
-
nodeSizes.set(uuid, size);
|
|
3017
|
-
return size;
|
|
3018
|
-
}
|
|
3019
|
-
const fallback = { width: 200, height: 100 };
|
|
3020
|
-
nodeSizes.set(uuid, fallback);
|
|
3021
|
-
return fallback;
|
|
3022
|
-
};
|
|
2067
|
+
// Gather node sizes from DOM
|
|
2068
|
+
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
2069
|
+
const getNodeSize = (uuid: string): { width: number; height: number } => {
|
|
2070
|
+
const element = this.querySelector(`[id="${uuid}"]`) as HTMLElement;
|
|
2071
|
+
if (element) {
|
|
2072
|
+
const size = {
|
|
2073
|
+
width: element.offsetWidth,
|
|
2074
|
+
height: element.offsetHeight
|
|
2075
|
+
};
|
|
2076
|
+
nodeSizes.set(uuid, size);
|
|
2077
|
+
return size;
|
|
2078
|
+
}
|
|
2079
|
+
const fallback = { width: 200, height: 100 };
|
|
2080
|
+
nodeSizes.set(uuid, fallback);
|
|
2081
|
+
return fallback;
|
|
2082
|
+
};
|
|
3023
2083
|
|
|
3024
2084
|
// Compute new layout
|
|
3025
2085
|
const newPositions = calculateLayeredLayout(
|
|
@@ -3193,119 +2253,6 @@ export class Editor extends RapidElement {
|
|
|
3193
2253
|
this.selectedItems.clear();
|
|
3194
2254
|
}
|
|
3195
2255
|
|
|
3196
|
-
private updateSelectionBox(event: MouseEvent): void {
|
|
3197
|
-
if (!this.selectionBox || !this.canvasMouseDown) return;
|
|
3198
|
-
|
|
3199
|
-
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
3200
|
-
if (!canvasRect) return;
|
|
3201
|
-
|
|
3202
|
-
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
3203
|
-
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
3204
|
-
|
|
3205
|
-
this.selectionBox = {
|
|
3206
|
-
...this.selectionBox,
|
|
3207
|
-
endX: relativeX,
|
|
3208
|
-
endY: relativeY
|
|
3209
|
-
};
|
|
3210
|
-
|
|
3211
|
-
// Update selected items based on selection box
|
|
3212
|
-
this.updateSelectedItemsFromBox();
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
|
-
private updateSelectedItemsFromBox(): void {
|
|
3216
|
-
if (!this.selectionBox) return;
|
|
3217
|
-
|
|
3218
|
-
const newSelection = new Set<string>();
|
|
3219
|
-
|
|
3220
|
-
const boxLeft = Math.min(this.selectionBox.startX, this.selectionBox.endX);
|
|
3221
|
-
const boxTop = Math.min(this.selectionBox.startY, this.selectionBox.endY);
|
|
3222
|
-
const boxRight = Math.max(this.selectionBox.startX, this.selectionBox.endX);
|
|
3223
|
-
const boxBottom = Math.max(
|
|
3224
|
-
this.selectionBox.startY,
|
|
3225
|
-
this.selectionBox.endY
|
|
3226
|
-
);
|
|
3227
|
-
|
|
3228
|
-
// Check nodes
|
|
3229
|
-
this.definition?.nodes.forEach((node) => {
|
|
3230
|
-
const nodeElement = this.querySelector(
|
|
3231
|
-
`[id="${node.uuid}"]`
|
|
3232
|
-
) as HTMLElement;
|
|
3233
|
-
if (nodeElement) {
|
|
3234
|
-
const position = this.definition._ui?.nodes[node.uuid]?.position;
|
|
3235
|
-
if (position) {
|
|
3236
|
-
const canvasRect =
|
|
3237
|
-
this.querySelector('#canvas')?.getBoundingClientRect();
|
|
3238
|
-
|
|
3239
|
-
if (canvasRect) {
|
|
3240
|
-
const nodeLeft = position.left;
|
|
3241
|
-
const nodeTop = position.top;
|
|
3242
|
-
const nodeRight = nodeLeft + nodeElement.offsetWidth;
|
|
3243
|
-
const nodeBottom = nodeTop + nodeElement.offsetHeight;
|
|
3244
|
-
|
|
3245
|
-
// Check if selection box intersects with node
|
|
3246
|
-
if (
|
|
3247
|
-
boxLeft < nodeRight &&
|
|
3248
|
-
boxRight > nodeLeft &&
|
|
3249
|
-
boxTop < nodeBottom &&
|
|
3250
|
-
boxBottom > nodeTop
|
|
3251
|
-
) {
|
|
3252
|
-
newSelection.add(node.uuid);
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
});
|
|
3258
|
-
|
|
3259
|
-
// Check sticky notes
|
|
3260
|
-
const stickies = this.definition?._ui?.stickies || {};
|
|
3261
|
-
Object.entries(stickies).forEach(([uuid, sticky]) => {
|
|
3262
|
-
if (sticky.position) {
|
|
3263
|
-
const stickyElement = this.querySelector(
|
|
3264
|
-
`temba-sticky-note[uuid="${uuid}"]`
|
|
3265
|
-
) as HTMLElement;
|
|
3266
|
-
|
|
3267
|
-
if (stickyElement) {
|
|
3268
|
-
// Use clientWidth/clientHeight instead of getBoundingClientRect() to get element dimensions
|
|
3269
|
-
// This avoids the coordinate system mismatch between viewport and canvas coordinates
|
|
3270
|
-
const width = stickyElement.clientWidth;
|
|
3271
|
-
const height = stickyElement.clientHeight;
|
|
3272
|
-
|
|
3273
|
-
// Use the canvas coordinates from the sticky's position
|
|
3274
|
-
const stickyLeft = sticky.position.left;
|
|
3275
|
-
const stickyTop = sticky.position.top;
|
|
3276
|
-
const stickyRight = stickyLeft + width;
|
|
3277
|
-
const stickyBottom = stickyTop + height;
|
|
3278
|
-
|
|
3279
|
-
// Check if selection box intersects with sticky
|
|
3280
|
-
if (
|
|
3281
|
-
boxLeft < stickyRight &&
|
|
3282
|
-
boxRight > stickyLeft &&
|
|
3283
|
-
boxTop < stickyBottom &&
|
|
3284
|
-
boxBottom > stickyTop
|
|
3285
|
-
) {
|
|
3286
|
-
newSelection.add(uuid);
|
|
3287
|
-
}
|
|
3288
|
-
}
|
|
3289
|
-
}
|
|
3290
|
-
});
|
|
3291
|
-
|
|
3292
|
-
this.selectedItems = newSelection;
|
|
3293
|
-
}
|
|
3294
|
-
|
|
3295
|
-
private renderSelectionBox(): TemplateResult | string {
|
|
3296
|
-
if (!this.selectionBox || !this.isSelecting) return '';
|
|
3297
|
-
|
|
3298
|
-
const left = Math.min(this.selectionBox.startX, this.selectionBox.endX);
|
|
3299
|
-
const top = Math.min(this.selectionBox.startY, this.selectionBox.endY);
|
|
3300
|
-
const width = Math.abs(this.selectionBox.endX - this.selectionBox.startX);
|
|
3301
|
-
const height = Math.abs(this.selectionBox.endY - this.selectionBox.startY);
|
|
3302
|
-
|
|
3303
|
-
return html`<div
|
|
3304
|
-
class="selection-box"
|
|
3305
|
-
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
|
|
3306
|
-
></div>`;
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
2256
|
private renderCanvasDropPreview(): TemplateResult | string {
|
|
3310
2257
|
if (!this.canvasDropPreview) return '';
|
|
3311
2258
|
|
|
@@ -3445,7 +2392,7 @@ export class Editor extends RapidElement {
|
|
|
3445
2392
|
* as needed. Sacred items (just moved/dropped/resized) keep their
|
|
3446
2393
|
* positions while other items are moved in the least-disruptive direction.
|
|
3447
2394
|
*/
|
|
3448
|
-
|
|
2395
|
+
public checkCollisionsAndReflow(sacredUuids: string[] = []): void {
|
|
3449
2396
|
if (!this.definition) return;
|
|
3450
2397
|
|
|
3451
2398
|
const allBounds: NodeBounds[] = [];
|
|
@@ -3489,892 +2436,20 @@ export class Editor extends RapidElement {
|
|
|
3489
2436
|
* Find the temba-flow-node element at the given viewport coordinates.
|
|
3490
2437
|
* Uses elementFromPoint which works for both mouse and touch input.
|
|
3491
2438
|
*/
|
|
3492
|
-
|
|
2439
|
+
public findTargetNodeAt(clientX: number, clientY: number): Element | null {
|
|
3493
2440
|
const el = document.elementFromPoint(clientX, clientY);
|
|
3494
2441
|
return el?.closest('temba-flow-node') ?? null;
|
|
3495
2442
|
}
|
|
3496
2443
|
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
* + handleCanvasMouseDown for touch: starts selection on empty canvas,
|
|
3500
|
-
* and detects double-tap to show the context menu.
|
|
3501
|
-
*/
|
|
3502
|
-
private handleCanvasTouchStart(event: TouchEvent): void {
|
|
3503
|
-
this.markTouchDevice();
|
|
3504
|
-
|
|
3505
|
-
const touch = event.touches[0];
|
|
3506
|
-
if (!touch) return;
|
|
3507
|
-
|
|
3508
|
-
// Only handle touches directly on canvas/grid (not on nodes)
|
|
3509
|
-
const target = event.target as HTMLElement;
|
|
3510
|
-
if (target.closest('.draggable')) return;
|
|
3511
|
-
if (target.id !== 'canvas' && target.id !== 'grid') return;
|
|
3512
|
-
|
|
3513
|
-
// Two-finger touch on canvas — record start position and enter the
|
|
3514
|
-
// two-finger state immediately (even before any touchmove). If the
|
|
3515
|
-
// fingers lift without panning, we show the context menu (handleTouchEnd).
|
|
3516
|
-
if (event.touches.length >= 2) {
|
|
3517
|
-
// Cancel any single-finger selection that the first touch started
|
|
3518
|
-
this.canvasMouseDown = false;
|
|
3519
|
-
this.isSelecting = false;
|
|
3520
|
-
this.selectionBox = null;
|
|
3521
|
-
|
|
3522
|
-
this.isTwoFingerPanning = true;
|
|
3523
|
-
this.twoFingerOnCanvas = true;
|
|
3524
|
-
this.twoFingerDidPan = false;
|
|
3525
|
-
this.twoFingerStartMidX =
|
|
3526
|
-
(event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
3527
|
-
this.twoFingerStartMidY =
|
|
3528
|
-
(event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
3529
|
-
this.lastPanX = this.twoFingerStartMidX;
|
|
3530
|
-
this.lastPanY = this.twoFingerStartMidY;
|
|
3531
|
-
return;
|
|
3532
|
-
}
|
|
3533
|
-
|
|
3534
|
-
// Start selection box (mirrors handleCanvasMouseDown)
|
|
3535
|
-
if (this.isReadOnly()) return;
|
|
3536
|
-
|
|
3537
|
-
this.canvasMouseDown = true;
|
|
3538
|
-
this.dragStartPos = { x: touch.clientX, y: touch.clientY };
|
|
3539
|
-
|
|
3540
|
-
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
3541
|
-
if (canvasRect) {
|
|
3542
|
-
this.selectedItems.clear();
|
|
2444
|
+
private updateCanvasSize(): void {
|
|
2445
|
+
if (!this.definition) return;
|
|
3543
2446
|
|
|
3544
|
-
|
|
3545
|
-
|
|
2447
|
+
const store = getStore();
|
|
2448
|
+
if (!store) return;
|
|
3546
2449
|
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
endX: relativeX,
|
|
3551
|
-
endY: relativeY
|
|
3552
|
-
};
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
event.preventDefault();
|
|
3556
|
-
}
|
|
3557
|
-
|
|
3558
|
-
/* c8 ignore stop */
|
|
3559
|
-
|
|
3560
|
-
private handleMouseMove(event: MouseEvent): void {
|
|
3561
|
-
// Handle selection box drawing
|
|
3562
|
-
if (this.canvasMouseDown && !this.isMouseDown) {
|
|
3563
|
-
this.isSelecting = true;
|
|
3564
|
-
this.updateSelectionBox(event);
|
|
3565
|
-
this.requestUpdate(); // Force re-render
|
|
3566
|
-
return;
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
if (this.plumber.connectionDragging) {
|
|
3570
|
-
this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
|
|
3571
|
-
this.startAutoScroll();
|
|
3572
|
-
|
|
3573
|
-
const targetNode = document.querySelector('temba-flow-node:hover');
|
|
3574
|
-
|
|
3575
|
-
// Clear previous target styles
|
|
3576
|
-
document.querySelectorAll('temba-flow-node').forEach((node) => {
|
|
3577
|
-
node.classList.remove(
|
|
3578
|
-
'connection-target-valid',
|
|
3579
|
-
'connection-target-invalid'
|
|
3580
|
-
);
|
|
3581
|
-
});
|
|
3582
|
-
|
|
3583
|
-
if (targetNode) {
|
|
3584
|
-
this.targetId = targetNode.getAttribute('uuid');
|
|
3585
|
-
// Check if target is different from source node (prevent self-targeting)
|
|
3586
|
-
this.isValidTarget = this.targetId !== this.dragFromNodeId;
|
|
3587
|
-
|
|
3588
|
-
// Apply visual feedback based on validity
|
|
3589
|
-
if (this.isValidTarget) {
|
|
3590
|
-
targetNode.classList.add('connection-target-valid');
|
|
3591
|
-
} else {
|
|
3592
|
-
targetNode.classList.add('connection-target-invalid');
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
// Hide connection placeholder when over a node
|
|
3596
|
-
this.connectionPlaceholder = null;
|
|
3597
|
-
} else {
|
|
3598
|
-
this.targetId = null;
|
|
3599
|
-
this.isValidTarget = true;
|
|
3600
|
-
|
|
3601
|
-
// Show connection placeholder when over empty canvas
|
|
3602
|
-
const canvas = this.querySelector('#canvas');
|
|
3603
|
-
if (canvas) {
|
|
3604
|
-
const canvasRect = canvas.getBoundingClientRect();
|
|
3605
|
-
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
3606
|
-
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
3607
|
-
|
|
3608
|
-
const placeholderWidth = 200;
|
|
3609
|
-
const placeholderHeight = 64;
|
|
3610
|
-
const arrowLength = ARROW_LENGTH;
|
|
3611
|
-
const cursorGap = CURSOR_GAP;
|
|
3612
|
-
|
|
3613
|
-
// Determine if cursor is above the source exit using stored sourceY
|
|
3614
|
-
const dragUp =
|
|
3615
|
-
this.connectionSourceY != null
|
|
3616
|
-
? relativeY < this.connectionSourceY
|
|
3617
|
-
: false;
|
|
3618
|
-
|
|
3619
|
-
let top: number;
|
|
3620
|
-
if (dragUp) {
|
|
3621
|
-
// Arrow points up: tip at cy + cursorGap.
|
|
3622
|
-
// Placeholder bottom should sit just above the arrow tip.
|
|
3623
|
-
top = relativeY + cursorGap - placeholderHeight;
|
|
3624
|
-
} else {
|
|
3625
|
-
// Arrow points down: tip at cy - cursorGap + arrowLength.
|
|
3626
|
-
// Placeholder top sits just below the arrow tip.
|
|
3627
|
-
top = relativeY - cursorGap + arrowLength;
|
|
3628
|
-
}
|
|
3629
|
-
|
|
3630
|
-
this.connectionPlaceholder = {
|
|
3631
|
-
position: {
|
|
3632
|
-
left: relativeX - placeholderWidth / 2,
|
|
3633
|
-
top
|
|
3634
|
-
},
|
|
3635
|
-
visible: true,
|
|
3636
|
-
dragUp
|
|
3637
|
-
};
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
|
|
3641
|
-
// Force update to show/hide placeholder
|
|
3642
|
-
this.requestUpdate();
|
|
3643
|
-
}
|
|
3644
|
-
|
|
3645
|
-
// Handle item dragging
|
|
3646
|
-
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
3647
|
-
|
|
3648
|
-
this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
|
|
3649
|
-
|
|
3650
|
-
const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
|
|
3651
|
-
const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
|
|
3652
|
-
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
3653
|
-
|
|
3654
|
-
// Only start dragging if we've moved beyond the threshold
|
|
3655
|
-
if (!this.isDragging && distance > DRAG_THRESHOLD) {
|
|
3656
|
-
this.isDragging = true;
|
|
3657
|
-
this.startAutoScroll();
|
|
3658
|
-
|
|
3659
|
-
// Snapshot the original drag context before any copy occurs
|
|
3660
|
-
this.originalDragItem = { ...this.currentDragItem };
|
|
3661
|
-
this.originalSelectedItems = new Set(this.selectedItems);
|
|
3662
|
-
|
|
3663
|
-
if (this.shiftDragCopy || event.shiftKey) {
|
|
3664
|
-
this.performShiftDragCopy();
|
|
3665
|
-
this.shiftDragCopy = false;
|
|
3666
|
-
} else {
|
|
3667
|
-
this.showDragHint();
|
|
3668
|
-
}
|
|
3669
|
-
}
|
|
3670
|
-
|
|
3671
|
-
// If we're actually dragging, update positions
|
|
3672
|
-
if (this.isDragging) {
|
|
3673
|
-
this.updateDragPositions();
|
|
3674
|
-
}
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
|
-
private performShiftDragCopy(): void {
|
|
3678
|
-
if (!this.originalDragItem) return;
|
|
3679
|
-
|
|
3680
|
-
// Always use the original items as the source for copying
|
|
3681
|
-
const itemsToCopy =
|
|
3682
|
-
this.originalSelectedItems?.has(this.originalDragItem.uuid) &&
|
|
3683
|
-
(this.originalSelectedItems?.size ?? 0) > 1
|
|
3684
|
-
? Array.from(this.originalSelectedItems!)
|
|
3685
|
-
: [this.originalDragItem.uuid];
|
|
3686
|
-
|
|
3687
|
-
if (itemsToCopy.length === 0) return;
|
|
3688
|
-
|
|
3689
|
-
const uuidMapping = getStore().getState().duplicateNodes(itemsToCopy);
|
|
3690
|
-
|
|
3691
|
-
// Track only the top-level duplicated item UUIDs (not internal
|
|
3692
|
-
// action/exit/category UUIDs). Accumulate across the discard window.
|
|
3693
|
-
for (const uuid of itemsToCopy) {
|
|
3694
|
-
const newUuid = uuidMapping[uuid];
|
|
3695
|
-
if (newUuid && !this.copiedItemUuids.includes(newUuid)) {
|
|
3696
|
-
this.copiedItemUuids.push(newUuid);
|
|
3697
|
-
}
|
|
3698
|
-
}
|
|
3699
|
-
this.currentDragIsCopy = true;
|
|
3700
|
-
|
|
3701
|
-
// Snap original items back to their start positions.
|
|
3702
|
-
// Set position while 'dragging' class is still applied so
|
|
3703
|
-
// transitions are disabled and the move is instant.
|
|
3704
|
-
for (const uuid of itemsToCopy) {
|
|
3705
|
-
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
3706
|
-
const type =
|
|
3707
|
-
element?.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
3708
|
-
const position = this.getPosition(uuid, type);
|
|
3709
|
-
if (element && position) {
|
|
3710
|
-
element.style.left = `${position.left}px`;
|
|
3711
|
-
element.style.top = `${position.top}px`;
|
|
3712
|
-
}
|
|
3713
|
-
}
|
|
3714
|
-
this.plumber.revalidate(itemsToCopy);
|
|
3715
|
-
// Force layout so the position is committed with transitions
|
|
3716
|
-
// disabled, then remove the dragging class.
|
|
3717
|
-
for (const uuid of itemsToCopy) {
|
|
3718
|
-
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
3719
|
-
if (element) {
|
|
3720
|
-
// Reading offsetHeight forces a synchronous layout
|
|
3721
|
-
void element.offsetHeight;
|
|
3722
|
-
element.classList.remove('dragging');
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
|
|
3726
|
-
// Update drag item to reference the copy
|
|
3727
|
-
const newDragUuid = uuidMapping[this.originalDragItem.uuid];
|
|
3728
|
-
if (newDragUuid) {
|
|
3729
|
-
this.currentDragItem = {
|
|
3730
|
-
...this.originalDragItem,
|
|
3731
|
-
uuid: newDragUuid
|
|
3732
|
-
};
|
|
3733
|
-
}
|
|
3734
|
-
|
|
3735
|
-
// Update selected items to reference copies
|
|
3736
|
-
if ((this.originalSelectedItems?.size ?? 0) > 1) {
|
|
3737
|
-
const newSelectedItems = new Set<string>();
|
|
3738
|
-
for (const uuid of this.originalSelectedItems!) {
|
|
3739
|
-
const newUuid = uuidMapping[uuid];
|
|
3740
|
-
newSelectedItems.add(newUuid || uuid);
|
|
3741
|
-
}
|
|
3742
|
-
this.selectedItems = newSelectedItems;
|
|
3743
|
-
}
|
|
3744
|
-
}
|
|
3745
|
-
|
|
3746
|
-
private markCopyElements(): void {
|
|
3747
|
-
for (const uuid of this.copiedItemUuids) {
|
|
3748
|
-
const el = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
3749
|
-
el?.classList.add('drag-copy');
|
|
3750
|
-
}
|
|
3751
|
-
}
|
|
3752
|
-
|
|
3753
|
-
private revertShiftDragCopy(): void {
|
|
3754
|
-
if (!this.originalDragItem) return;
|
|
3755
|
-
|
|
3756
|
-
// Remove the cloned items
|
|
3757
|
-
if (this.copiedItemUuids.length > 0) {
|
|
3758
|
-
const nodeUuids = this.copiedItemUuids.filter((uuid) =>
|
|
3759
|
-
this.definition.nodes.some((n) => n.uuid === uuid)
|
|
3760
|
-
);
|
|
3761
|
-
const stickyUuids = this.copiedItemUuids.filter(
|
|
3762
|
-
(uuid) => this.definition._ui?.stickies?.[uuid]
|
|
3763
|
-
);
|
|
3764
|
-
|
|
3765
|
-
if (nodeUuids.length > 0) {
|
|
3766
|
-
getStore().getState().removeNodes(nodeUuids);
|
|
3767
|
-
}
|
|
3768
|
-
if (stickyUuids.length > 0) {
|
|
3769
|
-
getStore().getState().removeStickyNotes(stickyUuids);
|
|
3770
|
-
}
|
|
3771
|
-
this.copiedItemUuids = [];
|
|
3772
|
-
}
|
|
3773
|
-
|
|
3774
|
-
this.currentDragIsCopy = false;
|
|
3775
|
-
|
|
3776
|
-
// The remove calls above set dirtyDate, but we're just reverting a
|
|
3777
|
-
// mid-drag copy — there's nothing to save yet. Clear it so no
|
|
3778
|
-
// revision is created while the drag is still in progress.
|
|
3779
|
-
getStore().getState().setDirtyDate(null);
|
|
3780
|
-
|
|
3781
|
-
// Restore drag context to originals
|
|
3782
|
-
this.currentDragItem = { ...this.originalDragItem };
|
|
3783
|
-
if (this.originalSelectedItems) {
|
|
3784
|
-
this.selectedItems = new Set(this.originalSelectedItems);
|
|
3785
|
-
}
|
|
3786
|
-
}
|
|
3787
|
-
|
|
3788
|
-
private updateDragPositions(): void {
|
|
3789
|
-
if (!this.currentDragItem || !this.lastPointerPos) return;
|
|
3790
|
-
|
|
3791
|
-
// Convert screen + scroll delta to canvas delta
|
|
3792
|
-
const deltaX =
|
|
3793
|
-
(this.lastPointerPos.clientX -
|
|
3794
|
-
this.dragStartPos.x +
|
|
3795
|
-
this.autoScrollDeltaX) /
|
|
3796
|
-
this.zoom;
|
|
3797
|
-
const deltaY =
|
|
3798
|
-
(this.lastPointerPos.clientY -
|
|
3799
|
-
this.dragStartPos.y +
|
|
3800
|
-
this.autoScrollDeltaY) /
|
|
3801
|
-
this.zoom;
|
|
3802
|
-
|
|
3803
|
-
const itemsToMove =
|
|
3804
|
-
this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
3805
|
-
this.selectedItems.size > 1
|
|
3806
|
-
? Array.from(this.selectedItems)
|
|
3807
|
-
: [this.currentDragItem.uuid];
|
|
3808
|
-
|
|
3809
|
-
itemsToMove.forEach((uuid) => {
|
|
3810
|
-
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
3811
|
-
if (element) {
|
|
3812
|
-
const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
3813
|
-
const position = this.getPosition(uuid, type);
|
|
3814
|
-
|
|
3815
|
-
if (position) {
|
|
3816
|
-
element.style.left = `${position.left + deltaX}px`;
|
|
3817
|
-
element.style.top = `${position.top + deltaY}px`;
|
|
3818
|
-
element.classList.add('dragging');
|
|
3819
|
-
if (this.currentDragIsCopy) {
|
|
3820
|
-
element.classList.add('drag-copy');
|
|
3821
|
-
}
|
|
3822
|
-
}
|
|
3823
|
-
}
|
|
3824
|
-
});
|
|
3825
|
-
|
|
3826
|
-
this.plumber.revalidate(itemsToMove);
|
|
3827
|
-
|
|
3828
|
-
}
|
|
3829
|
-
|
|
3830
|
-
private startAutoScroll(): void {
|
|
3831
|
-
if (this.autoScrollAnimationId !== null) return;
|
|
3832
|
-
|
|
3833
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
3834
|
-
if (!editor) return;
|
|
3835
|
-
|
|
3836
|
-
const tick = () => {
|
|
3837
|
-
if (
|
|
3838
|
-
(!this.isDragging && !this.plumber?.connectionDragging) ||
|
|
3839
|
-
!this.lastPointerPos
|
|
3840
|
-
) {
|
|
3841
|
-
this.autoScrollAnimationId = null;
|
|
3842
|
-
return;
|
|
3843
|
-
}
|
|
3844
|
-
|
|
3845
|
-
const editorRect = editor.getBoundingClientRect();
|
|
3846
|
-
const mouseX = this.lastPointerPos.clientX;
|
|
3847
|
-
const mouseY = this.lastPointerPos.clientY;
|
|
3848
|
-
|
|
3849
|
-
let scrollDx = 0;
|
|
3850
|
-
let scrollDy = 0;
|
|
3851
|
-
|
|
3852
|
-
// Left edge (including beyond)
|
|
3853
|
-
const distFromLeft = mouseX - editorRect.left;
|
|
3854
|
-
if (distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
|
|
3855
|
-
const beyond = distFromLeft < 0;
|
|
3856
|
-
const ratio = Math.min(
|
|
3857
|
-
1,
|
|
3858
|
-
1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE
|
|
3859
|
-
);
|
|
3860
|
-
const speed =
|
|
3861
|
-
AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
|
|
3862
|
-
scrollDx = -(ratio * speed);
|
|
3863
|
-
}
|
|
3864
|
-
|
|
3865
|
-
// Right edge (including beyond)
|
|
3866
|
-
const distFromRight = editorRect.right - mouseX;
|
|
3867
|
-
if (distFromRight < AUTO_SCROLL_EDGE_ZONE) {
|
|
3868
|
-
const beyond = distFromRight < 0;
|
|
3869
|
-
const ratio = Math.min(
|
|
3870
|
-
1,
|
|
3871
|
-
1 - distFromRight / AUTO_SCROLL_EDGE_ZONE
|
|
3872
|
-
);
|
|
3873
|
-
const speed =
|
|
3874
|
-
AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
|
|
3875
|
-
scrollDx = ratio * speed;
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
|
-
// Top edge (including beyond)
|
|
3879
|
-
const distFromTop = mouseY - editorRect.top;
|
|
3880
|
-
if (distFromTop < AUTO_SCROLL_EDGE_ZONE) {
|
|
3881
|
-
const beyond = distFromTop < 0;
|
|
3882
|
-
const ratio = Math.min(
|
|
3883
|
-
1,
|
|
3884
|
-
1 - distFromTop / AUTO_SCROLL_EDGE_ZONE
|
|
3885
|
-
);
|
|
3886
|
-
const speed =
|
|
3887
|
-
AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
|
|
3888
|
-
scrollDy = -(ratio * speed);
|
|
3889
|
-
}
|
|
3890
|
-
|
|
3891
|
-
// Bottom edge (including beyond)
|
|
3892
|
-
const distFromBottom = editorRect.bottom - mouseY;
|
|
3893
|
-
if (distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
|
|
3894
|
-
const beyond = distFromBottom < 0;
|
|
3895
|
-
const ratio = Math.min(
|
|
3896
|
-
1,
|
|
3897
|
-
1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE
|
|
3898
|
-
);
|
|
3899
|
-
const speed =
|
|
3900
|
-
AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
|
|
3901
|
-
scrollDy = ratio * speed;
|
|
3902
|
-
}
|
|
3903
|
-
|
|
3904
|
-
if (scrollDx !== 0 || scrollDy !== 0) {
|
|
3905
|
-
const beforeScrollLeft = editor.scrollLeft;
|
|
3906
|
-
const beforeScrollTop = editor.scrollTop;
|
|
3907
|
-
|
|
3908
|
-
// Expand canvas if scrolling toward bottom/right edges
|
|
3909
|
-
// Convert from scroll space to canvas space for expandCanvas
|
|
3910
|
-
if (scrollDx > 0 || scrollDy > 0) {
|
|
3911
|
-
const neededWidth =
|
|
3912
|
-
(editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
|
|
3913
|
-
const neededHeight =
|
|
3914
|
-
(editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
|
|
3915
|
-
getStore()?.getState()?.expandCanvas(neededWidth, neededHeight);
|
|
3916
|
-
}
|
|
3917
|
-
|
|
3918
|
-
editor.scrollLeft += scrollDx;
|
|
3919
|
-
editor.scrollTop += scrollDy;
|
|
3920
|
-
|
|
3921
|
-
// Track actual scroll delta (browser clamps at boundaries)
|
|
3922
|
-
const actualDx = editor.scrollLeft - beforeScrollLeft;
|
|
3923
|
-
const actualDy = editor.scrollTop - beforeScrollTop;
|
|
3924
|
-
this.autoScrollDeltaX += actualDx;
|
|
3925
|
-
this.autoScrollDeltaY += actualDy;
|
|
3926
|
-
|
|
3927
|
-
if (actualDx !== 0 || actualDy !== 0) {
|
|
3928
|
-
this.updateDragPositions();
|
|
3929
|
-
}
|
|
3930
|
-
}
|
|
3931
|
-
|
|
3932
|
-
this.autoScrollAnimationId = requestAnimationFrame(tick);
|
|
3933
|
-
};
|
|
3934
|
-
|
|
3935
|
-
this.autoScrollAnimationId = requestAnimationFrame(tick);
|
|
3936
|
-
}
|
|
3937
|
-
|
|
3938
|
-
private stopAutoScroll(): void {
|
|
3939
|
-
if (this.autoScrollAnimationId !== null) {
|
|
3940
|
-
cancelAnimationFrame(this.autoScrollAnimationId);
|
|
3941
|
-
this.autoScrollAnimationId = null;
|
|
3942
|
-
}
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
private handleMouseUp(event: MouseEvent): void {
|
|
3946
|
-
// Handle selection box completion
|
|
3947
|
-
if (this.canvasMouseDown && this.isSelecting) {
|
|
3948
|
-
this.isSelecting = false;
|
|
3949
|
-
this.selectionBox = null;
|
|
3950
|
-
this.canvasMouseDown = false;
|
|
3951
|
-
this.requestUpdate();
|
|
3952
|
-
return;
|
|
3953
|
-
}
|
|
3954
|
-
|
|
3955
|
-
// Handle canvas click (clear selection)
|
|
3956
|
-
if (this.canvasMouseDown && !this.isSelecting) {
|
|
3957
|
-
this.canvasMouseDown = false;
|
|
3958
|
-
return;
|
|
3959
|
-
}
|
|
3960
|
-
|
|
3961
|
-
// Handle item drag completion
|
|
3962
|
-
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
3963
|
-
|
|
3964
|
-
this.stopAutoScroll();
|
|
3965
|
-
|
|
3966
|
-
// If we were actually dragging, handle the drag end
|
|
3967
|
-
if (this.isDragging) {
|
|
3968
|
-
// Convert screen + scroll delta to canvas delta
|
|
3969
|
-
const deltaX =
|
|
3970
|
-
(event.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
|
|
3971
|
-
this.zoom;
|
|
3972
|
-
const deltaY =
|
|
3973
|
-
(event.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
|
|
3974
|
-
this.zoom;
|
|
3975
|
-
|
|
3976
|
-
// Determine what items were moved
|
|
3977
|
-
const itemsToMove =
|
|
3978
|
-
this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
3979
|
-
this.selectedItems.size > 1
|
|
3980
|
-
? Array.from(this.selectedItems)
|
|
3981
|
-
: [this.currentDragItem.uuid];
|
|
3982
|
-
|
|
3983
|
-
// Update positions for all moved items
|
|
3984
|
-
const newPositions: { [uuid: string]: FlowPosition } = {};
|
|
3985
|
-
|
|
3986
|
-
itemsToMove.forEach((uuid) => {
|
|
3987
|
-
const type = this.definition.nodes.find((node) => node.uuid === uuid)
|
|
3988
|
-
? 'node'
|
|
3989
|
-
: 'sticky';
|
|
3990
|
-
const position = this.getPosition(uuid, type);
|
|
3991
|
-
|
|
3992
|
-
if (position) {
|
|
3993
|
-
const newLeft = position.left + deltaX;
|
|
3994
|
-
const newTop = position.top + deltaY;
|
|
3995
|
-
|
|
3996
|
-
// Snap to 20px grid for final position
|
|
3997
|
-
const snappedLeft = snapToGrid(newLeft);
|
|
3998
|
-
const snappedTop = snapToGrid(newTop);
|
|
3999
|
-
|
|
4000
|
-
const newPosition = { left: snappedLeft, top: snappedTop };
|
|
4001
|
-
newPositions[uuid] = newPosition;
|
|
4002
|
-
|
|
4003
|
-
// Remove dragging/copy classes
|
|
4004
|
-
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
4005
|
-
if (element) {
|
|
4006
|
-
element.classList.remove('dragging', 'drag-copy');
|
|
4007
|
-
element.style.left = `${snappedLeft}px`;
|
|
4008
|
-
element.style.top = `${snappedTop}px`;
|
|
4009
|
-
}
|
|
4010
|
-
}
|
|
4011
|
-
});
|
|
4012
|
-
|
|
4013
|
-
if (Object.keys(newPositions).length > 0) {
|
|
4014
|
-
// Suppress save if this was a shift+drag copy — the pending card
|
|
4015
|
-
// gives the user a chance to abandon before the revision is saved.
|
|
4016
|
-
if (this.currentDragIsCopy) {
|
|
4017
|
-
this.pendingTimer.pending = true;
|
|
4018
|
-
this.capturePositionsOnce();
|
|
4019
|
-
}
|
|
4020
|
-
|
|
4021
|
-
getStore().getState().updateCanvasPositions(newPositions);
|
|
4022
|
-
|
|
4023
|
-
// Check for collisions and reflow after updating positions
|
|
4024
|
-
// Allow DOM to update before checking collisions
|
|
4025
|
-
setTimeout(() => {
|
|
4026
|
-
this.checkCollisionsAndReflow(itemsToMove);
|
|
4027
|
-
this.plumber.repaintEverything();
|
|
4028
|
-
}, 0);
|
|
4029
|
-
}
|
|
4030
|
-
|
|
4031
|
-
// Show/reset pending-changes card for shift+drag copy
|
|
4032
|
-
if (this.currentDragIsCopy) {
|
|
4033
|
-
this.pendingTimer.start();
|
|
4034
|
-
}
|
|
4035
|
-
|
|
4036
|
-
this.selectedItems.clear();
|
|
4037
|
-
}
|
|
4038
|
-
|
|
4039
|
-
// Reset all drag state
|
|
4040
|
-
this.hideDragHint();
|
|
4041
|
-
this.isDragging = false;
|
|
4042
|
-
this.isMouseDown = false;
|
|
4043
|
-
this.shiftDragCopy = false;
|
|
4044
|
-
this.currentDragIsCopy = false;
|
|
4045
|
-
this.currentDragItem = null;
|
|
4046
|
-
this.originalDragItem = null;
|
|
4047
|
-
this.originalSelectedItems = null;
|
|
4048
|
-
this.canvasMouseDown = false;
|
|
4049
|
-
this.autoScrollDeltaX = 0;
|
|
4050
|
-
this.autoScrollDeltaY = 0;
|
|
4051
|
-
this.lastPointerPos = null;
|
|
4052
|
-
}
|
|
4053
|
-
|
|
4054
|
-
/* c8 ignore start -- touch-only handlers */
|
|
4055
|
-
|
|
4056
|
-
/**
|
|
4057
|
-
* Handle touch move on the document — mirrors handleMouseMove for
|
|
4058
|
-
* both connection dragging and node/sticky dragging on touch devices.
|
|
4059
|
-
*/
|
|
4060
|
-
private handleTouchMove(event: TouchEvent): void {
|
|
4061
|
-
// --- Two-finger panning ---
|
|
4062
|
-
if (event.touches.length >= 2) {
|
|
4063
|
-
event.preventDefault();
|
|
4064
|
-
const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
4065
|
-
const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
4066
|
-
|
|
4067
|
-
if (this.isTwoFingerPanning) {
|
|
4068
|
-
const dx = this.lastPanX - midX;
|
|
4069
|
-
const dy = this.lastPanY - midY;
|
|
4070
|
-
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
|
4071
|
-
this.twoFingerDidPan = true;
|
|
4072
|
-
}
|
|
4073
|
-
const editor = this.querySelector('#editor') as HTMLElement;
|
|
4074
|
-
if (editor) {
|
|
4075
|
-
editor.scrollBy(dx, dy);
|
|
4076
|
-
}
|
|
4077
|
-
}
|
|
4078
|
-
|
|
4079
|
-
// Cancel any in-progress single-finger actions
|
|
4080
|
-
this.canvasMouseDown = false;
|
|
4081
|
-
this.isSelecting = false;
|
|
4082
|
-
this.selectionBox = null;
|
|
4083
|
-
|
|
4084
|
-
this.isTwoFingerPanning = true;
|
|
4085
|
-
this.lastPanX = midX;
|
|
4086
|
-
this.lastPanY = midY;
|
|
4087
|
-
return;
|
|
4088
|
-
}
|
|
4089
|
-
|
|
4090
|
-
const touch = event.touches[0];
|
|
4091
|
-
if (!touch) return;
|
|
4092
|
-
|
|
4093
|
-
// --- Selection box drawing ---
|
|
4094
|
-
if (this.canvasMouseDown && !this.isMouseDown) {
|
|
4095
|
-
event.preventDefault();
|
|
4096
|
-
this.isSelecting = true;
|
|
4097
|
-
|
|
4098
|
-
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
4099
|
-
if (canvasRect && this.selectionBox) {
|
|
4100
|
-
this.selectionBox = {
|
|
4101
|
-
...this.selectionBox,
|
|
4102
|
-
endX: (touch.clientX - canvasRect.left) / this.zoom,
|
|
4103
|
-
endY: (touch.clientY - canvasRect.top) / this.zoom
|
|
4104
|
-
};
|
|
4105
|
-
this.updateSelectedItemsFromBox();
|
|
4106
|
-
}
|
|
4107
|
-
|
|
4108
|
-
this.requestUpdate();
|
|
4109
|
-
return;
|
|
4110
|
-
}
|
|
4111
|
-
|
|
4112
|
-
// --- Connection dragging ---
|
|
4113
|
-
if (this.plumber.connectionDragging) {
|
|
4114
|
-
event.preventDefault();
|
|
4115
|
-
|
|
4116
|
-
const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
|
|
4117
|
-
|
|
4118
|
-
// Clear previous target styles
|
|
4119
|
-
document.querySelectorAll('temba-flow-node').forEach((node) => {
|
|
4120
|
-
node.classList.remove(
|
|
4121
|
-
'connection-target-valid',
|
|
4122
|
-
'connection-target-invalid'
|
|
4123
|
-
);
|
|
4124
|
-
});
|
|
4125
|
-
|
|
4126
|
-
if (targetNode) {
|
|
4127
|
-
this.targetId = targetNode.getAttribute('uuid');
|
|
4128
|
-
this.isValidTarget = this.targetId !== this.dragFromNodeId;
|
|
4129
|
-
|
|
4130
|
-
if (this.isValidTarget) {
|
|
4131
|
-
targetNode.classList.add('connection-target-valid');
|
|
4132
|
-
} else {
|
|
4133
|
-
targetNode.classList.add('connection-target-invalid');
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
this.connectionPlaceholder = null;
|
|
4137
|
-
} else {
|
|
4138
|
-
this.targetId = null;
|
|
4139
|
-
this.isValidTarget = true;
|
|
4140
|
-
|
|
4141
|
-
const canvas = this.querySelector('#canvas');
|
|
4142
|
-
if (canvas) {
|
|
4143
|
-
const canvasRect = canvas.getBoundingClientRect();
|
|
4144
|
-
const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
|
|
4145
|
-
const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
|
|
4146
|
-
|
|
4147
|
-
const placeholderWidth = 200;
|
|
4148
|
-
const placeholderHeight = 64;
|
|
4149
|
-
const arrowLength = ARROW_LENGTH;
|
|
4150
|
-
const cursorGap = CURSOR_GAP;
|
|
4151
|
-
|
|
4152
|
-
const dragUp =
|
|
4153
|
-
this.connectionSourceY != null
|
|
4154
|
-
? relativeY < this.connectionSourceY
|
|
4155
|
-
: false;
|
|
4156
|
-
|
|
4157
|
-
let top: number;
|
|
4158
|
-
if (dragUp) {
|
|
4159
|
-
top = relativeY + cursorGap - placeholderHeight;
|
|
4160
|
-
} else {
|
|
4161
|
-
top = relativeY - cursorGap + arrowLength;
|
|
4162
|
-
}
|
|
4163
|
-
|
|
4164
|
-
this.connectionPlaceholder = {
|
|
4165
|
-
position: {
|
|
4166
|
-
left: relativeX - placeholderWidth / 2,
|
|
4167
|
-
top
|
|
4168
|
-
},
|
|
4169
|
-
visible: true,
|
|
4170
|
-
dragUp
|
|
4171
|
-
};
|
|
4172
|
-
}
|
|
4173
|
-
}
|
|
4174
|
-
|
|
4175
|
-
this.requestUpdate();
|
|
4176
|
-
return;
|
|
4177
|
-
}
|
|
4178
|
-
|
|
4179
|
-
// --- Node/sticky dragging ---
|
|
4180
|
-
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
4181
|
-
|
|
4182
|
-
this.lastPointerPos = { clientX: touch.clientX, clientY: touch.clientY };
|
|
4183
|
-
|
|
4184
|
-
const deltaX = touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
|
|
4185
|
-
const deltaY = touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
|
|
4186
|
-
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
4187
|
-
|
|
4188
|
-
if (!this.isDragging && distance > DRAG_THRESHOLD) {
|
|
4189
|
-
this.isDragging = true;
|
|
4190
|
-
this.startAutoScroll();
|
|
4191
|
-
}
|
|
4192
|
-
|
|
4193
|
-
// Only prevent default scrolling once we're actually dragging.
|
|
4194
|
-
// Before the threshold, allow the browser to fire synthetic click
|
|
4195
|
-
// events for taps on buttons (remove, add-action, etc.).
|
|
4196
|
-
if (this.isDragging) {
|
|
4197
|
-
event.preventDefault();
|
|
4198
|
-
this.updateDragPositions();
|
|
4199
|
-
}
|
|
4200
|
-
}
|
|
4201
|
-
|
|
4202
|
-
/**
|
|
4203
|
-
* Handle touch end on the document — mirrors handleMouseUp for
|
|
4204
|
-
* both connection dragging and node/sticky dragging on touch devices.
|
|
4205
|
-
*/
|
|
4206
|
-
private handleTouchEnd(event: TouchEvent): void {
|
|
4207
|
-
// --- Two-finger gesture end ---
|
|
4208
|
-
if (this.isTwoFingerPanning) {
|
|
4209
|
-
if (event.touches.length === 0) {
|
|
4210
|
-
const didPan = this.twoFingerDidPan;
|
|
4211
|
-
const onCanvas = this.twoFingerOnCanvas;
|
|
4212
|
-
const midX = this.twoFingerStartMidX;
|
|
4213
|
-
const midY = this.twoFingerStartMidY;
|
|
4214
|
-
|
|
4215
|
-
// Reset state
|
|
4216
|
-
this.isTwoFingerPanning = false;
|
|
4217
|
-
this.twoFingerOnCanvas = false;
|
|
4218
|
-
this.twoFingerDidPan = false;
|
|
4219
|
-
|
|
4220
|
-
// Two-finger tap (no pan) on canvas → show context menu
|
|
4221
|
-
if (!didPan && onCanvas) {
|
|
4222
|
-
this.showContextMenuAt(midX, midY);
|
|
4223
|
-
}
|
|
4224
|
-
}
|
|
4225
|
-
return;
|
|
4226
|
-
}
|
|
4227
|
-
|
|
4228
|
-
const touch = event.changedTouches[0];
|
|
4229
|
-
|
|
4230
|
-
// --- Selection box completion ---
|
|
4231
|
-
if (this.canvasMouseDown && this.isSelecting) {
|
|
4232
|
-
this.isSelecting = false;
|
|
4233
|
-
this.selectionBox = null;
|
|
4234
|
-
this.canvasMouseDown = false;
|
|
4235
|
-
this.requestUpdate();
|
|
4236
|
-
return;
|
|
4237
|
-
}
|
|
4238
|
-
|
|
4239
|
-
// --- Canvas tap (no drag) — clear selection ---
|
|
4240
|
-
if (this.canvasMouseDown && !this.isSelecting) {
|
|
4241
|
-
this.canvasMouseDown = false;
|
|
4242
|
-
return;
|
|
4243
|
-
}
|
|
4244
|
-
|
|
4245
|
-
// --- Connection dragging ---
|
|
4246
|
-
if (this.plumber.connectionDragging) {
|
|
4247
|
-
if (touch) {
|
|
4248
|
-
const targetNode = this.findTargetNodeAt(touch.clientX, touch.clientY);
|
|
4249
|
-
if (targetNode) {
|
|
4250
|
-
this.targetId = targetNode.getAttribute('uuid');
|
|
4251
|
-
this.isValidTarget = this.targetId !== this.dragFromNodeId;
|
|
4252
|
-
}
|
|
4253
|
-
}
|
|
4254
|
-
return;
|
|
4255
|
-
}
|
|
4256
|
-
|
|
4257
|
-
// --- Node/sticky dragging ---
|
|
4258
|
-
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
4259
|
-
|
|
4260
|
-
this.stopAutoScroll();
|
|
4261
|
-
|
|
4262
|
-
if (this.isDragging && touch) {
|
|
4263
|
-
const deltaX =
|
|
4264
|
-
(touch.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
|
|
4265
|
-
this.zoom;
|
|
4266
|
-
const deltaY =
|
|
4267
|
-
(touch.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
|
|
4268
|
-
this.zoom;
|
|
4269
|
-
|
|
4270
|
-
const itemsToMove =
|
|
4271
|
-
this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
4272
|
-
this.selectedItems.size > 1
|
|
4273
|
-
? Array.from(this.selectedItems)
|
|
4274
|
-
: [this.currentDragItem.uuid];
|
|
4275
|
-
|
|
4276
|
-
const newPositions: { [uuid: string]: FlowPosition } = {};
|
|
4277
|
-
|
|
4278
|
-
itemsToMove.forEach((uuid) => {
|
|
4279
|
-
const type = this.definition.nodes.find((node) => node.uuid === uuid)
|
|
4280
|
-
? 'node'
|
|
4281
|
-
: 'sticky';
|
|
4282
|
-
const position = this.getPosition(uuid, type);
|
|
4283
|
-
|
|
4284
|
-
if (position) {
|
|
4285
|
-
const newLeft = position.left + deltaX;
|
|
4286
|
-
const newTop = position.top + deltaY;
|
|
4287
|
-
const snappedLeft = snapToGrid(newLeft);
|
|
4288
|
-
const snappedTop = snapToGrid(newTop);
|
|
4289
|
-
|
|
4290
|
-
newPositions[uuid] = { left: snappedLeft, top: snappedTop };
|
|
4291
|
-
|
|
4292
|
-
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
4293
|
-
if (element) {
|
|
4294
|
-
element.classList.remove('dragging');
|
|
4295
|
-
element.style.left = `${snappedLeft}px`;
|
|
4296
|
-
element.style.top = `${snappedTop}px`;
|
|
4297
|
-
}
|
|
4298
|
-
}
|
|
4299
|
-
});
|
|
4300
|
-
|
|
4301
|
-
if (Object.keys(newPositions).length > 0) {
|
|
4302
|
-
getStore().getState().updateCanvasPositions(newPositions);
|
|
4303
|
-
|
|
4304
|
-
// Check for collisions and reflow after updating positions
|
|
4305
|
-
setTimeout(() => {
|
|
4306
|
-
this.checkCollisionsAndReflow(itemsToMove);
|
|
4307
|
-
this.plumber.repaintEverything();
|
|
4308
|
-
}, 0);
|
|
4309
|
-
}
|
|
4310
|
-
|
|
4311
|
-
this.selectedItems.clear();
|
|
4312
|
-
}
|
|
4313
|
-
|
|
4314
|
-
// Reset all drag state
|
|
4315
|
-
this.isDragging = false;
|
|
4316
|
-
this.isMouseDown = false;
|
|
4317
|
-
this.currentDragItem = null;
|
|
4318
|
-
this.canvasMouseDown = false;
|
|
4319
|
-
this.autoScrollDeltaX = 0;
|
|
4320
|
-
this.autoScrollDeltaY = 0;
|
|
4321
|
-
this.lastPointerPos = null;
|
|
4322
|
-
}
|
|
4323
|
-
|
|
4324
|
-
/**
|
|
4325
|
-
* Handle touchcancel — reset all touch-related state so the editor
|
|
4326
|
-
* doesn't get stuck in a partial drag/selection mode.
|
|
4327
|
-
*/
|
|
4328
|
-
private handleTouchCancel(): void {
|
|
4329
|
-
this.isTwoFingerPanning = false;
|
|
4330
|
-
this.isSelecting = false;
|
|
4331
|
-
this.selectionBox = null;
|
|
4332
|
-
this.canvasMouseDown = false;
|
|
4333
|
-
|
|
4334
|
-
if (this.isDragging && this.currentDragItem) {
|
|
4335
|
-
// Remove dragging class from all moved items
|
|
4336
|
-
const itemsToReset =
|
|
4337
|
-
this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
4338
|
-
this.selectedItems.size > 1
|
|
4339
|
-
? Array.from(this.selectedItems)
|
|
4340
|
-
: [this.currentDragItem.uuid];
|
|
4341
|
-
itemsToReset.forEach((uuid) => {
|
|
4342
|
-
const el = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
4343
|
-
if (el) el.classList.remove('dragging');
|
|
4344
|
-
});
|
|
4345
|
-
}
|
|
4346
|
-
|
|
4347
|
-
this.stopAutoScroll();
|
|
4348
|
-
this.isDragging = false;
|
|
4349
|
-
this.isMouseDown = false;
|
|
4350
|
-
this.currentDragItem = null;
|
|
4351
|
-
this.autoScrollDeltaX = 0;
|
|
4352
|
-
this.autoScrollDeltaY = 0;
|
|
4353
|
-
this.lastPointerPos = null;
|
|
4354
|
-
|
|
4355
|
-
// Clear connection drag visual state
|
|
4356
|
-
document.querySelectorAll('temba-flow-node').forEach((node) => {
|
|
4357
|
-
node.classList.remove(
|
|
4358
|
-
'connection-target-valid',
|
|
4359
|
-
'connection-target-invalid'
|
|
4360
|
-
);
|
|
4361
|
-
});
|
|
4362
|
-
this.connectionPlaceholder = null;
|
|
4363
|
-
|
|
4364
|
-
this.requestUpdate();
|
|
4365
|
-
}
|
|
4366
|
-
|
|
4367
|
-
/* c8 ignore stop */
|
|
4368
|
-
|
|
4369
|
-
private updateCanvasSize(): void {
|
|
4370
|
-
if (!this.definition) return;
|
|
4371
|
-
|
|
4372
|
-
const store = getStore();
|
|
4373
|
-
if (!store) return;
|
|
4374
|
-
|
|
4375
|
-
// Calculate required canvas size based on all elements
|
|
4376
|
-
let maxWidth = 0;
|
|
4377
|
-
let maxHeight = 0;
|
|
2450
|
+
// Calculate required canvas size based on all elements
|
|
2451
|
+
let maxWidth = 0;
|
|
2452
|
+
let maxHeight = 0;
|
|
4378
2453
|
|
|
4379
2454
|
// Check node positions
|
|
4380
2455
|
this.definition.nodes.forEach((node) => {
|
|
@@ -4448,7 +2523,7 @@ export class Editor extends RapidElement {
|
|
|
4448
2523
|
event.stopPropagation();
|
|
4449
2524
|
|
|
4450
2525
|
// Ensure no sticky note contenteditable retains focus
|
|
4451
|
-
this.blurActiveContentEditable();
|
|
2526
|
+
this.dragManager.blurActiveContentEditable();
|
|
4452
2527
|
|
|
4453
2528
|
this.showContextMenuAt(event.clientX, event.clientY);
|
|
4454
2529
|
}
|
|
@@ -4457,7 +2532,7 @@ export class Editor extends RapidElement {
|
|
|
4457
2532
|
* Show the canvas context menu at the given viewport coordinates.
|
|
4458
2533
|
* Shared by right-click (mouse) and double-tap (touch).
|
|
4459
2534
|
*/
|
|
4460
|
-
|
|
2535
|
+
public showContextMenuAt(clientX: number, clientY: number): void {
|
|
4461
2536
|
if (this.isReadOnly()) return;
|
|
4462
2537
|
|
|
4463
2538
|
const canvas = this.querySelector('#canvas');
|
|
@@ -5052,7 +3127,9 @@ export class Editor extends RapidElement {
|
|
|
5052
3127
|
this.actionDragIsCopy = true;
|
|
5053
3128
|
this.showActionOriginal(true);
|
|
5054
3129
|
} else {
|
|
5055
|
-
this.
|
|
3130
|
+
(this.querySelector('#drag-hint') as HTMLElement)?.classList.add(
|
|
3131
|
+
'visible'
|
|
3132
|
+
);
|
|
5056
3133
|
}
|
|
5057
3134
|
}
|
|
5058
3135
|
|
|
@@ -5215,7 +3292,9 @@ export class Editor extends RapidElement {
|
|
|
5215
3292
|
this.actionDragTargetNodeUuid = null;
|
|
5216
3293
|
this.isActionExternalDrag = false;
|
|
5217
3294
|
this.actionDragIsCopy = false;
|
|
5218
|
-
this.
|
|
3295
|
+
(this.querySelector('#drag-hint') as HTMLElement)?.classList.remove(
|
|
3296
|
+
'visible'
|
|
3297
|
+
);
|
|
5219
3298
|
this.actionDragLastDetail = null;
|
|
5220
3299
|
}
|
|
5221
3300
|
|
|
@@ -5301,7 +3380,9 @@ export class Editor extends RapidElement {
|
|
|
5301
3380
|
this.actionDragIsCopy = false;
|
|
5302
3381
|
this.actionDragLastDetail = null;
|
|
5303
3382
|
this.previousActionDragTargetNodeUuid = null;
|
|
5304
|
-
this.
|
|
3383
|
+
(this.querySelector('#drag-hint') as HTMLElement)?.classList.remove(
|
|
3384
|
+
'visible'
|
|
3385
|
+
);
|
|
5305
3386
|
|
|
5306
3387
|
// Check if we're dropping on an existing execute_actions node
|
|
5307
3388
|
const targetNodeUuid = this.actionDragTargetNodeUuid;
|
|
@@ -5319,628 +3400,120 @@ export class Editor extends RapidElement {
|
|
|
5319
3400
|
sourceNodeUuid: nodeUuid,
|
|
5320
3401
|
actionIndex,
|
|
5321
3402
|
mouseX,
|
|
5322
|
-
mouseY,
|
|
5323
|
-
isCopy
|
|
5324
|
-
},
|
|
5325
|
-
bubbles: false
|
|
5326
|
-
})
|
|
5327
|
-
);
|
|
5328
|
-
}
|
|
5329
|
-
|
|
5330
|
-
// Clear state
|
|
5331
|
-
this.canvasDropPreview = null;
|
|
5332
|
-
this.actionDragTargetNodeUuid = null;
|
|
5333
|
-
return;
|
|
5334
|
-
}
|
|
5335
|
-
|
|
5336
|
-
// If this is the last action and not copying, do nothing
|
|
5337
|
-
// Last actions can only be moved to other nodes, not dropped on canvas
|
|
5338
|
-
if (isLastAction && !isCopy) {
|
|
5339
|
-
this.canvasDropPreview = null;
|
|
5340
|
-
this.actionDragTargetNodeUuid = null;
|
|
5341
|
-
return;
|
|
5342
|
-
}
|
|
5343
|
-
|
|
5344
|
-
// Not dropping on another node, create a new one on canvas
|
|
5345
|
-
// Snap to grid for the final drop position
|
|
5346
|
-
const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
|
|
5347
|
-
|
|
5348
|
-
if (!isCopy) {
|
|
5349
|
-
// remove the action from the original node
|
|
5350
|
-
const originalNode = this.definition.nodes.find(
|
|
5351
|
-
(n) => n.uuid === nodeUuid
|
|
5352
|
-
);
|
|
5353
|
-
if (!originalNode) return;
|
|
5354
|
-
|
|
5355
|
-
const updatedActions = originalNode.actions.filter(
|
|
5356
|
-
(_a, idx) => idx !== actionIndex
|
|
5357
|
-
);
|
|
5358
|
-
|
|
5359
|
-
// if no actions remain, delete the node
|
|
5360
|
-
if (updatedActions.length === 0) {
|
|
5361
|
-
// Use deleteNodes to properly clean up Plumber connections before removing
|
|
5362
|
-
this.deleteNodes([nodeUuid]);
|
|
5363
|
-
} else {
|
|
5364
|
-
// update the node
|
|
5365
|
-
const updatedNode = { ...originalNode, actions: updatedActions };
|
|
5366
|
-
getStore()?.getState().updateNode(nodeUuid, updatedNode);
|
|
5367
|
-
}
|
|
5368
|
-
}
|
|
5369
|
-
|
|
5370
|
-
// create a new execute_actions node with the dropped action
|
|
5371
|
-
// When copying, generate a fresh UUID so the clone doesn't share the original's
|
|
5372
|
-
const droppedAction = isCopy
|
|
5373
|
-
? { ...action, uuid: generateUUID() }
|
|
5374
|
-
: action;
|
|
5375
|
-
const newNode: Node = {
|
|
5376
|
-
uuid: generateUUID(),
|
|
5377
|
-
actions: [droppedAction],
|
|
5378
|
-
exits: [
|
|
5379
|
-
{
|
|
5380
|
-
uuid: generateUUID(),
|
|
5381
|
-
destination_uuid: null
|
|
5382
|
-
}
|
|
5383
|
-
]
|
|
5384
|
-
};
|
|
5385
|
-
|
|
5386
|
-
const newNodeUI: NodeUI = {
|
|
5387
|
-
position,
|
|
5388
|
-
type: 'execute_actions',
|
|
5389
|
-
config: {}
|
|
5390
|
-
};
|
|
5391
|
-
|
|
5392
|
-
// add the new node
|
|
5393
|
-
getStore()?.getState().addNode(newNode, newNodeUI);
|
|
5394
|
-
|
|
5395
|
-
// Copy localizations from the original action to the new one
|
|
5396
|
-
if (isCopy) {
|
|
5397
|
-
this.copyActionLocalizations(action.uuid, droppedAction.uuid);
|
|
5398
|
-
}
|
|
5399
|
-
|
|
5400
|
-
// clear the preview
|
|
5401
|
-
this.canvasDropPreview = null;
|
|
5402
|
-
this.actionDragTargetNodeUuid = null;
|
|
5403
|
-
|
|
5404
|
-
// Check for collisions and reflow after adding new node
|
|
5405
|
-
requestAnimationFrame(() => {
|
|
5406
|
-
this.checkCollisionsAndReflow([newNode.uuid]);
|
|
5407
|
-
});
|
|
5408
|
-
}
|
|
5409
|
-
|
|
5410
|
-
/** Copy all localization entries from one action UUID to another. */
|
|
5411
|
-
private copyActionLocalizations(
|
|
5412
|
-
sourceUuid: string,
|
|
5413
|
-
targetUuid: string
|
|
5414
|
-
): void {
|
|
5415
|
-
const localization = this.definition?.localization;
|
|
5416
|
-
if (!localization) return;
|
|
5417
|
-
const store = getStore()?.getState();
|
|
5418
|
-
if (!store) return;
|
|
5419
|
-
for (const langCode of Object.keys(localization)) {
|
|
5420
|
-
const entry = localization[langCode]?.[sourceUuid];
|
|
5421
|
-
if (entry) {
|
|
5422
|
-
store.updateLocalization(
|
|
5423
|
-
langCode,
|
|
5424
|
-
targetUuid,
|
|
5425
|
-
JSON.parse(JSON.stringify(entry))
|
|
5426
|
-
);
|
|
5427
|
-
}
|
|
5428
|
-
}
|
|
5429
|
-
}
|
|
5430
|
-
|
|
5431
|
-
private getLocalizationLanguages(): Array<{ code: string; name: string }> {
|
|
5432
|
-
if (!this.definition) {
|
|
5433
|
-
return [];
|
|
5434
|
-
}
|
|
5435
|
-
|
|
5436
|
-
const baseLanguage = this.definition.language;
|
|
5437
|
-
return this.getAvailableLanguages().filter(
|
|
5438
|
-
(lang) => lang.code !== baseLanguage
|
|
5439
|
-
);
|
|
5440
|
-
}
|
|
5441
|
-
|
|
5442
|
-
private getLocalizationProgress(languageCode: string): {
|
|
5443
|
-
total: number;
|
|
5444
|
-
localized: number;
|
|
5445
|
-
} {
|
|
5446
|
-
if (
|
|
5447
|
-
!this.definition ||
|
|
5448
|
-
!languageCode ||
|
|
5449
|
-
languageCode === this.definition.language
|
|
5450
|
-
) {
|
|
5451
|
-
return { total: 0, localized: 0 };
|
|
5452
|
-
}
|
|
5453
|
-
|
|
5454
|
-
const bundles = this.buildTranslationBundles(languageCode);
|
|
5455
|
-
return this.getTranslationCounts(bundles);
|
|
5456
|
-
}
|
|
5457
|
-
|
|
5458
|
-
private getLanguageLocalization(languageCode: string): Record<string, any> {
|
|
5459
|
-
if (!this.definition?.localization) {
|
|
5460
|
-
return {};
|
|
5461
|
-
}
|
|
5462
|
-
return this.definition.localization[languageCode] || {};
|
|
5463
|
-
}
|
|
5464
|
-
|
|
5465
|
-
private buildTranslationBundles(
|
|
5466
|
-
languageCode: string = this.languageCode
|
|
5467
|
-
): TranslationBundle[] {
|
|
5468
|
-
if (
|
|
5469
|
-
!this.definition ||
|
|
5470
|
-
!languageCode ||
|
|
5471
|
-
languageCode === this.definition.language
|
|
5472
|
-
) {
|
|
5473
|
-
return [];
|
|
5474
|
-
}
|
|
5475
|
-
|
|
5476
|
-
const languageLocalization = this.getLanguageLocalization(languageCode);
|
|
5477
|
-
const bundles: TranslationBundle[] = [];
|
|
5478
|
-
|
|
5479
|
-
this.definition.nodes.forEach((node) => {
|
|
5480
|
-
node.actions?.forEach((action) => {
|
|
5481
|
-
const config = ACTION_CONFIG[action.type];
|
|
5482
|
-
if (!config?.localizable || config.localizable.length === 0) {
|
|
5483
|
-
return;
|
|
5484
|
-
}
|
|
5485
|
-
|
|
5486
|
-
// For send_msg actions, only count 'text' for progress tracking
|
|
5487
|
-
// (quick_replies and attachments are still localizable but don't count toward progress)
|
|
5488
|
-
const localizableKeys =
|
|
5489
|
-
action.type === 'send_msg'
|
|
5490
|
-
? config.localizable.filter((key) => key === 'text')
|
|
5491
|
-
: config.localizable;
|
|
5492
|
-
|
|
5493
|
-
const translations = this.findTranslations(
|
|
5494
|
-
'property',
|
|
5495
|
-
action.uuid,
|
|
5496
|
-
localizableKeys,
|
|
5497
|
-
action,
|
|
5498
|
-
languageLocalization
|
|
5499
|
-
);
|
|
5500
|
-
|
|
5501
|
-
if (translations.length > 0) {
|
|
5502
|
-
bundles.push({
|
|
5503
|
-
nodeUuid: node.uuid,
|
|
5504
|
-
actionUuid: action.uuid,
|
|
5505
|
-
translations
|
|
5506
|
-
});
|
|
5507
|
-
}
|
|
5508
|
-
});
|
|
5509
|
-
|
|
5510
|
-
const nodeUI = this.definition._ui?.nodes?.[node.uuid];
|
|
5511
|
-
const nodeType = nodeUI?.type;
|
|
5512
|
-
if (!nodeType) {
|
|
5513
|
-
return;
|
|
5514
|
-
}
|
|
5515
|
-
|
|
5516
|
-
const nodeConfig = NODE_CONFIG[nodeType];
|
|
5517
|
-
// Include rule (case argument) translations when localizeRules is set
|
|
5518
|
-
if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
|
|
5519
|
-
const ruleTranslations = node.router.cases
|
|
5520
|
-
.filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
|
|
5521
|
-
.flatMap((c) =>
|
|
5522
|
-
this.findTranslations(
|
|
5523
|
-
'property',
|
|
5524
|
-
c.uuid,
|
|
5525
|
-
['arguments'],
|
|
5526
|
-
c,
|
|
5527
|
-
languageLocalization
|
|
5528
|
-
)
|
|
5529
|
-
);
|
|
5530
|
-
|
|
5531
|
-
if (ruleTranslations.length > 0) {
|
|
5532
|
-
bundles.push({
|
|
5533
|
-
nodeUuid: node.uuid,
|
|
5534
|
-
translations: ruleTranslations
|
|
5535
|
-
});
|
|
5536
|
-
}
|
|
5537
|
-
}
|
|
5538
|
-
|
|
5539
|
-
if (
|
|
5540
|
-
nodeUI?.config?.localizeCategories &&
|
|
5541
|
-
nodeConfig?.localizable === 'categories' &&
|
|
5542
|
-
node.router?.categories?.length
|
|
5543
|
-
) {
|
|
5544
|
-
const translatableCategories = getTranslatableCategoriesForNode(
|
|
5545
|
-
nodeType,
|
|
5546
|
-
node.router.categories
|
|
5547
|
-
);
|
|
5548
|
-
const categoryTranslations = translatableCategories.flatMap(
|
|
5549
|
-
(category) =>
|
|
5550
|
-
this.findTranslations(
|
|
5551
|
-
'category',
|
|
5552
|
-
category.uuid,
|
|
5553
|
-
['name'],
|
|
5554
|
-
category,
|
|
5555
|
-
languageLocalization
|
|
5556
|
-
)
|
|
5557
|
-
);
|
|
5558
|
-
|
|
5559
|
-
if (categoryTranslations.length > 0) {
|
|
5560
|
-
bundles.push({
|
|
5561
|
-
nodeUuid: node.uuid,
|
|
5562
|
-
translations: categoryTranslations
|
|
5563
|
-
});
|
|
5564
|
-
}
|
|
5565
|
-
}
|
|
5566
|
-
});
|
|
5567
|
-
|
|
5568
|
-
return bundles;
|
|
5569
|
-
}
|
|
5570
|
-
|
|
5571
|
-
private findTranslations(
|
|
5572
|
-
type: TranslationType,
|
|
5573
|
-
uuid: string,
|
|
5574
|
-
localizeableKeys: string[],
|
|
5575
|
-
source: any,
|
|
5576
|
-
localization: Record<string, any>
|
|
5577
|
-
): TranslationEntry[] {
|
|
5578
|
-
const translations: TranslationEntry[] = [];
|
|
5579
|
-
|
|
5580
|
-
localizeableKeys.forEach((attribute) => {
|
|
5581
|
-
if (attribute === 'quick_replies') {
|
|
5582
|
-
return;
|
|
5583
|
-
}
|
|
5584
|
-
|
|
5585
|
-
const pathSegments = attribute.split('.');
|
|
5586
|
-
let from: any = source;
|
|
5587
|
-
let to: any = [];
|
|
5588
|
-
|
|
5589
|
-
while (pathSegments.length > 0 && from) {
|
|
5590
|
-
if (from.uuid) {
|
|
5591
|
-
to = localization[from.uuid];
|
|
5592
|
-
}
|
|
5593
|
-
|
|
5594
|
-
const path = pathSegments.shift();
|
|
5595
|
-
if (!path) {
|
|
5596
|
-
break;
|
|
5597
|
-
}
|
|
5598
|
-
|
|
5599
|
-
if (to) {
|
|
5600
|
-
to = to[path];
|
|
5601
|
-
}
|
|
5602
|
-
from = from[path];
|
|
5603
|
-
}
|
|
5604
|
-
|
|
5605
|
-
if (!from) {
|
|
5606
|
-
return;
|
|
5607
|
-
}
|
|
5608
|
-
|
|
5609
|
-
const fromValue = this.formatTranslationValue(from);
|
|
5610
|
-
if (!fromValue) {
|
|
5611
|
-
return;
|
|
5612
|
-
}
|
|
5613
|
-
|
|
5614
|
-
const toValue = to ? this.formatTranslationValue(to) : null;
|
|
5615
|
-
|
|
5616
|
-
translations.push({
|
|
5617
|
-
uuid,
|
|
5618
|
-
type,
|
|
5619
|
-
attribute,
|
|
5620
|
-
from: fromValue,
|
|
5621
|
-
to: toValue
|
|
5622
|
-
});
|
|
5623
|
-
});
|
|
5624
|
-
|
|
5625
|
-
return translations;
|
|
5626
|
-
}
|
|
5627
|
-
|
|
5628
|
-
private formatTranslationValue(value: any): string | null {
|
|
5629
|
-
if (value === null || value === undefined) {
|
|
5630
|
-
return null;
|
|
5631
|
-
}
|
|
5632
|
-
|
|
5633
|
-
if (Array.isArray(value)) {
|
|
5634
|
-
const normalized = value
|
|
5635
|
-
.map((entry) => this.formatTranslationValue(entry))
|
|
5636
|
-
.filter((entry) => !!entry) as string[];
|
|
5637
|
-
return normalized.length > 0 ? normalized.join(', ') : null;
|
|
5638
|
-
}
|
|
5639
|
-
|
|
5640
|
-
if (typeof value === 'object') {
|
|
5641
|
-
if ('name' in value && value.name) {
|
|
5642
|
-
return String(value.name);
|
|
5643
|
-
}
|
|
5644
|
-
|
|
5645
|
-
if ('arguments' in value && Array.isArray(value.arguments)) {
|
|
5646
|
-
return value.arguments.join(' ');
|
|
5647
|
-
}
|
|
5648
|
-
|
|
5649
|
-
return null;
|
|
5650
|
-
}
|
|
5651
|
-
|
|
5652
|
-
if (typeof value === 'number') {
|
|
5653
|
-
return value.toString();
|
|
5654
|
-
}
|
|
5655
|
-
|
|
5656
|
-
if (typeof value === 'string') {
|
|
5657
|
-
const trimmed = value.trim();
|
|
5658
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
5659
|
-
}
|
|
5660
|
-
|
|
5661
|
-
return null;
|
|
5662
|
-
}
|
|
5663
|
-
|
|
5664
|
-
private getTranslationCounts(bundles: TranslationBundle[]): {
|
|
5665
|
-
total: number;
|
|
5666
|
-
localized: number;
|
|
5667
|
-
} {
|
|
5668
|
-
return bundles.reduce(
|
|
5669
|
-
(counts, bundle) => {
|
|
5670
|
-
bundle.translations.forEach((translation) => {
|
|
5671
|
-
counts.total += 1;
|
|
5672
|
-
if (translation.to && translation.to.trim().length > 0) {
|
|
5673
|
-
counts.localized += 1;
|
|
5674
|
-
}
|
|
5675
|
-
});
|
|
5676
|
-
return counts;
|
|
5677
|
-
},
|
|
5678
|
-
{ total: 0, localized: 0 }
|
|
5679
|
-
);
|
|
5680
|
-
}
|
|
5681
|
-
|
|
5682
|
-
private handleLocalizationLanguageSelect(languageCode: string): void {
|
|
5683
|
-
if (languageCode === this.languageCode) {
|
|
5684
|
-
return;
|
|
5685
|
-
}
|
|
5686
|
-
this.handleLanguageChange(languageCode);
|
|
5687
|
-
}
|
|
5688
|
-
|
|
5689
|
-
private handleLocalizationLanguageSelectChange(event: CustomEvent): void {
|
|
5690
|
-
const select = event.target as any;
|
|
5691
|
-
const nextValue = select?.values?.[0]?.value;
|
|
5692
|
-
if (nextValue) {
|
|
5693
|
-
this.handleLocalizationLanguageSelect(nextValue);
|
|
5694
|
-
} else {
|
|
5695
|
-
// Cleared — return to base language
|
|
5696
|
-
const baseLanguage = this.definition?.language;
|
|
5697
|
-
if (baseLanguage) {
|
|
5698
|
-
this.handleLanguageChange(baseLanguage);
|
|
5699
|
-
}
|
|
5700
|
-
}
|
|
5701
|
-
}
|
|
5702
|
-
|
|
5703
|
-
private handleLocalizationWindowClosed(): void {
|
|
5704
|
-
this.localizationWindowHidden = true;
|
|
5705
|
-
}
|
|
5706
|
-
|
|
5707
|
-
private toggleTranslationSettings(): void {
|
|
5708
|
-
this.translationSettingsExpanded = !this.translationSettingsExpanded;
|
|
5709
|
-
}
|
|
5710
|
-
|
|
5711
|
-
private handleLocalizationProgressToggleClick(event: MouseEvent): void {
|
|
5712
|
-
const target = event.target as HTMLElement;
|
|
5713
|
-
if (target.closest('.translation-settings-toggle')) {
|
|
5714
|
-
return;
|
|
5715
|
-
}
|
|
5716
|
-
this.toggleTranslationSettings();
|
|
5717
|
-
}
|
|
5718
|
-
|
|
5719
|
-
private handleLocalizationProgressToggleKeydown(event: KeyboardEvent): void {
|
|
5720
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
5721
|
-
event.preventDefault();
|
|
5722
|
-
this.toggleTranslationSettings();
|
|
5723
|
-
}
|
|
5724
|
-
}
|
|
5725
|
-
|
|
5726
|
-
private hasAnyNodeWithLocalizeCategories(): boolean {
|
|
5727
|
-
if (!this.definition?._ui?.nodes) return false;
|
|
5728
|
-
return Object.values(this.definition._ui.nodes).some(
|
|
5729
|
-
(nodeUI: any) => nodeUI?.config?.localizeCategories
|
|
5730
|
-
);
|
|
5731
|
-
}
|
|
5732
|
-
|
|
5733
|
-
private async handleAutoTranslateClick(event?: Event): Promise<void> {
|
|
5734
|
-
event?.preventDefault();
|
|
5735
|
-
event?.stopPropagation();
|
|
5736
|
-
if (this.viewingRevision) {
|
|
5737
|
-
return;
|
|
5738
|
-
}
|
|
5739
|
-
|
|
5740
|
-
if (this.autoTranslating) {
|
|
5741
|
-
this.autoTranslating = false;
|
|
5742
|
-
return;
|
|
5743
|
-
}
|
|
5744
|
-
|
|
5745
|
-
this.autoTranslateDialogOpen = true;
|
|
5746
|
-
}
|
|
5747
|
-
|
|
5748
|
-
private handleAutoTranslateDialogButton(event: CustomEvent): void {
|
|
5749
|
-
const button = event.detail?.button;
|
|
5750
|
-
if (!button) {
|
|
5751
|
-
return;
|
|
5752
|
-
}
|
|
5753
|
-
|
|
5754
|
-
if (button.name === 'Translate') {
|
|
5755
|
-
if (!this.autoTranslateModel) {
|
|
5756
|
-
return;
|
|
5757
|
-
}
|
|
5758
|
-
this.autoTranslateDialogOpen = false;
|
|
5759
|
-
this.autoTranslateError = null;
|
|
5760
|
-
this.autoTranslating = true;
|
|
5761
|
-
this.runAutoTranslation().catch((error) => {
|
|
5762
|
-
console.error('Auto translation failed', error);
|
|
5763
|
-
this.autoTranslateError = 'Auto translation failed. Please try again.';
|
|
5764
|
-
this.autoTranslating = false;
|
|
5765
|
-
});
|
|
5766
|
-
} else if (button.name === 'Cancel' || button.name === 'Close') {
|
|
5767
|
-
this.autoTranslateDialogOpen = false;
|
|
5768
|
-
}
|
|
5769
|
-
}
|
|
5770
|
-
|
|
5771
|
-
private handleAutoTranslateModelChange(event: Event): void {
|
|
5772
|
-
const select = event.target as any;
|
|
5773
|
-
const nextModel = select?.values?.[0] || null;
|
|
5774
|
-
this.autoTranslateModel = nextModel;
|
|
5775
|
-
}
|
|
5776
|
-
|
|
5777
|
-
private shouldTranslateValue(text: string): boolean {
|
|
5778
|
-
if (!text) {
|
|
5779
|
-
return false;
|
|
5780
|
-
}
|
|
5781
|
-
const trimmed = text.trim();
|
|
5782
|
-
if (trimmed.length <= 1) {
|
|
5783
|
-
return false;
|
|
5784
|
-
}
|
|
5785
|
-
if (/^\d+$/.test(trimmed)) {
|
|
5786
|
-
return false;
|
|
5787
|
-
}
|
|
5788
|
-
return true;
|
|
5789
|
-
}
|
|
5790
|
-
|
|
5791
|
-
private async requestAutoTranslation(text: string): Promise<string | null> {
|
|
5792
|
-
if (!this.autoTranslateModel || !this.definition) {
|
|
5793
|
-
return null;
|
|
5794
|
-
}
|
|
5795
|
-
|
|
5796
|
-
const payload = {
|
|
5797
|
-
text,
|
|
5798
|
-
lang: {
|
|
5799
|
-
from: this.definition.language,
|
|
5800
|
-
to: this.languageCode
|
|
3403
|
+
mouseY,
|
|
3404
|
+
isCopy
|
|
3405
|
+
},
|
|
3406
|
+
bubbles: false
|
|
3407
|
+
})
|
|
3408
|
+
);
|
|
5801
3409
|
}
|
|
5802
|
-
};
|
|
5803
|
-
|
|
5804
|
-
const response = await postJSON(
|
|
5805
|
-
`/llm/translate/${this.autoTranslateModel.uuid}/`,
|
|
5806
|
-
payload
|
|
5807
|
-
);
|
|
5808
|
-
|
|
5809
|
-
if (response?.status === 200) {
|
|
5810
|
-
const result = response.json?.result || response.json?.text;
|
|
5811
|
-
return result ? String(result) : null;
|
|
5812
|
-
}
|
|
5813
|
-
|
|
5814
|
-
throw new Error('Auto translation request failed');
|
|
5815
|
-
}
|
|
5816
|
-
|
|
5817
|
-
private applyLocalizationUpdates(
|
|
5818
|
-
updates: LocalizationUpdate[],
|
|
5819
|
-
autoTranslated = false
|
|
5820
|
-
): void {
|
|
5821
|
-
if (!updates.length || !this.definition) {
|
|
5822
|
-
return;
|
|
5823
|
-
}
|
|
5824
3410
|
|
|
5825
|
-
|
|
5826
|
-
|
|
3411
|
+
// Clear state
|
|
3412
|
+
this.canvasDropPreview = null;
|
|
3413
|
+
this.actionDragTargetNodeUuid = null;
|
|
5827
3414
|
return;
|
|
5828
3415
|
}
|
|
5829
3416
|
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
}
|
|
5836
|
-
acc[key] = Array.isArray(value) ? value : [value];
|
|
5837
|
-
return acc;
|
|
5838
|
-
},
|
|
5839
|
-
{} as Record<string, any>
|
|
5840
|
-
);
|
|
5841
|
-
|
|
5842
|
-
const existing =
|
|
5843
|
-
this.definition.localization?.[this.languageCode]?.[uuid] || {};
|
|
5844
|
-
const merged = { ...existing, ...normalized };
|
|
5845
|
-
|
|
5846
|
-
store.getState().updateLocalization(this.languageCode, uuid, merged);
|
|
5847
|
-
|
|
5848
|
-
if (autoTranslated) {
|
|
5849
|
-
zustand
|
|
5850
|
-
.getState()
|
|
5851
|
-
.markAutoTranslated(
|
|
5852
|
-
this.languageCode,
|
|
5853
|
-
uuid,
|
|
5854
|
-
Object.keys(translations)
|
|
5855
|
-
);
|
|
5856
|
-
}
|
|
5857
|
-
});
|
|
5858
|
-
}
|
|
5859
|
-
|
|
5860
|
-
private async runAutoTranslation(): Promise<void> {
|
|
5861
|
-
if (
|
|
5862
|
-
!this.definition ||
|
|
5863
|
-
this.languageCode === this.definition.language ||
|
|
5864
|
-
!this.autoTranslateModel
|
|
5865
|
-
) {
|
|
5866
|
-
this.autoTranslating = false;
|
|
3417
|
+
// If this is the last action and not copying, do nothing
|
|
3418
|
+
// Last actions can only be moved to other nodes, not dropped on canvas
|
|
3419
|
+
if (isLastAction && !isCopy) {
|
|
3420
|
+
this.canvasDropPreview = null;
|
|
3421
|
+
this.actionDragTargetNodeUuid = null;
|
|
5867
3422
|
return;
|
|
5868
3423
|
}
|
|
5869
3424
|
|
|
5870
|
-
|
|
3425
|
+
// Not dropping on another node, create a new one on canvas
|
|
3426
|
+
// Snap to grid for the final drop position
|
|
3427
|
+
const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
|
|
5871
3428
|
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
3429
|
+
if (!isCopy) {
|
|
3430
|
+
// remove the action from the original node
|
|
3431
|
+
const originalNode = this.definition.nodes.find(
|
|
3432
|
+
(n) => n.uuid === nodeUuid
|
|
3433
|
+
);
|
|
3434
|
+
if (!originalNode) return;
|
|
5876
3435
|
|
|
5877
|
-
const
|
|
5878
|
-
(
|
|
3436
|
+
const updatedActions = originalNode.actions.filter(
|
|
3437
|
+
(_a, idx) => idx !== actionIndex
|
|
5879
3438
|
);
|
|
5880
3439
|
|
|
5881
|
-
if
|
|
5882
|
-
|
|
3440
|
+
// if no actions remain, delete the node
|
|
3441
|
+
if (updatedActions.length === 0) {
|
|
3442
|
+
// Use deleteNodes to properly clean up Plumber connections before removing
|
|
3443
|
+
this.deleteNodes([nodeUuid]);
|
|
3444
|
+
} else {
|
|
3445
|
+
// update the node
|
|
3446
|
+
const updatedNode = { ...originalNode, actions: updatedActions };
|
|
3447
|
+
getStore()?.getState().updateNode(nodeUuid, updatedNode);
|
|
5883
3448
|
}
|
|
3449
|
+
}
|
|
5884
3450
|
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
3451
|
+
// create a new execute_actions node with the dropped action
|
|
3452
|
+
// When copying, generate a fresh UUID so the clone doesn't share the original's
|
|
3453
|
+
const droppedAction = isCopy ? { ...action, uuid: generateUUID() } : action;
|
|
3454
|
+
const newNode: Node = {
|
|
3455
|
+
uuid: generateUUID(),
|
|
3456
|
+
actions: [droppedAction],
|
|
3457
|
+
exits: [
|
|
3458
|
+
{
|
|
3459
|
+
uuid: generateUUID(),
|
|
3460
|
+
destination_uuid: null
|
|
5890
3461
|
}
|
|
3462
|
+
]
|
|
3463
|
+
};
|
|
5891
3464
|
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
3465
|
+
const newNodeUI: NodeUI = {
|
|
3466
|
+
position,
|
|
3467
|
+
type: 'execute_actions',
|
|
3468
|
+
config: {}
|
|
3469
|
+
};
|
|
5895
3470
|
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
updates.push({
|
|
5899
|
-
uuid: translation.uuid,
|
|
5900
|
-
translations: { [translation.attribute]: cached }
|
|
5901
|
-
});
|
|
5902
|
-
continue;
|
|
5903
|
-
}
|
|
3471
|
+
// add the new node
|
|
3472
|
+
getStore()?.getState().addNode(newNode, newNodeUI);
|
|
5904
3473
|
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
updates.push({
|
|
5910
|
-
uuid: translation.uuid,
|
|
5911
|
-
translations: { [translation.attribute]: result }
|
|
5912
|
-
});
|
|
5913
|
-
}
|
|
5914
|
-
} catch (error) {
|
|
5915
|
-
console.error('Auto translation request failed', error);
|
|
5916
|
-
this.autoTranslateError =
|
|
5917
|
-
'Auto translation failed. Please try again.';
|
|
5918
|
-
this.autoTranslating = false;
|
|
5919
|
-
break;
|
|
5920
|
-
}
|
|
5921
|
-
}
|
|
3474
|
+
// Copy localizations from the original action to the new one
|
|
3475
|
+
if (isCopy) {
|
|
3476
|
+
this.copyActionLocalizations(action.uuid, droppedAction.uuid);
|
|
3477
|
+
}
|
|
5922
3478
|
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
3479
|
+
// clear the preview
|
|
3480
|
+
this.canvasDropPreview = null;
|
|
3481
|
+
this.actionDragTargetNodeUuid = null;
|
|
5926
3482
|
|
|
5927
|
-
|
|
5928
|
-
|
|
3483
|
+
// Check for collisions and reflow after adding new node
|
|
3484
|
+
requestAnimationFrame(() => {
|
|
3485
|
+
this.checkCollisionsAndReflow([newNode.uuid]);
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
/** Copy all localization entries from one action UUID to another. */
|
|
3490
|
+
private copyActionLocalizations(
|
|
3491
|
+
sourceUuid: string,
|
|
3492
|
+
targetUuid: string
|
|
3493
|
+
): void {
|
|
3494
|
+
const localization = this.definition?.localization;
|
|
3495
|
+
if (!localization) return;
|
|
3496
|
+
const store = getStore()?.getState();
|
|
3497
|
+
if (!store) return;
|
|
3498
|
+
for (const langCode of Object.keys(localization)) {
|
|
3499
|
+
const entry = localization[langCode]?.[sourceUuid];
|
|
3500
|
+
if (entry) {
|
|
3501
|
+
store.updateLocalization(
|
|
3502
|
+
langCode,
|
|
3503
|
+
targetUuid,
|
|
3504
|
+
JSON.parse(JSON.stringify(entry))
|
|
3505
|
+
);
|
|
5929
3506
|
}
|
|
5930
3507
|
}
|
|
5931
|
-
|
|
5932
|
-
this.autoTranslating = false;
|
|
5933
3508
|
}
|
|
5934
3509
|
|
|
5935
3510
|
private closeOpenWindows(): void {
|
|
5936
3511
|
if (!this.issuesWindowHidden) {
|
|
5937
|
-
this.
|
|
3512
|
+
this.issuesWindowHidden = true;
|
|
5938
3513
|
}
|
|
5939
3514
|
if (!this.revisionsWindowHidden) {
|
|
5940
|
-
this.
|
|
5941
|
-
|
|
5942
|
-
if (!this.localizationWindowHidden) {
|
|
5943
|
-
this.handleLocalizationWindowClosed();
|
|
3515
|
+
this.getRevisionsWindow()?.close();
|
|
3516
|
+
this.revisionsWindowHidden = true;
|
|
5944
3517
|
}
|
|
5945
3518
|
if (this.simulatorActive) {
|
|
5946
3519
|
const simulator = document.querySelector('temba-simulator') as any;
|
|
@@ -5950,39 +3523,40 @@ export class Editor extends RapidElement {
|
|
|
5950
3523
|
|
|
5951
3524
|
private closeFloatingWindows(): void {
|
|
5952
3525
|
if (!this.issuesWindowHidden) {
|
|
5953
|
-
this.
|
|
3526
|
+
this.issuesWindowHidden = true;
|
|
5954
3527
|
}
|
|
5955
3528
|
if (!this.revisionsWindowHidden) {
|
|
5956
|
-
this.
|
|
5957
|
-
|
|
5958
|
-
if (!this.localizationWindowHidden) {
|
|
5959
|
-
this.handleLocalizationWindowClosed();
|
|
3529
|
+
this.getRevisionsWindow()?.close();
|
|
3530
|
+
this.revisionsWindowHidden = true;
|
|
5960
3531
|
}
|
|
5961
3532
|
}
|
|
5962
3533
|
|
|
3534
|
+
private getRevisionsWindow(): RevisionsWindow | null {
|
|
3535
|
+
return this.querySelector(
|
|
3536
|
+
'temba-revisions-window'
|
|
3537
|
+
) as RevisionsWindow | null;
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// --- Issues window event handlers ---
|
|
3541
|
+
|
|
5963
3542
|
private handleIssuesTabClick(): void {
|
|
5964
3543
|
if (!this.issuesWindowHidden) {
|
|
5965
|
-
this.
|
|
3544
|
+
this.issuesWindowHidden = true;
|
|
5966
3545
|
return;
|
|
5967
3546
|
}
|
|
5968
3547
|
this.closeOpenWindows();
|
|
5969
3548
|
this.issuesWindowHidden = false;
|
|
5970
3549
|
}
|
|
5971
3550
|
|
|
5972
|
-
private
|
|
5973
|
-
|
|
5974
|
-
}
|
|
5975
|
-
|
|
5976
|
-
private handleIssueItemClick(issue: FlowIssue): void {
|
|
5977
|
-
const issuesWindow = document.getElementById(
|
|
5978
|
-
'issues-window'
|
|
5979
|
-
) as FloatingWindow;
|
|
5980
|
-
issuesWindow?.handleClose();
|
|
3551
|
+
private handleIssueSelected(e: CustomEvent): void {
|
|
3552
|
+
const { issue } = e.detail;
|
|
5981
3553
|
this.issuesWindowHidden = true;
|
|
5982
3554
|
|
|
5983
3555
|
this.focusNode(issue.node_uuid);
|
|
5984
3556
|
|
|
5985
|
-
const node = this.definition.nodes.find(
|
|
3557
|
+
const node = this.definition.nodes.find(
|
|
3558
|
+
(n) => n.uuid === issue.node_uuid
|
|
3559
|
+
);
|
|
5986
3560
|
if (!node) return;
|
|
5987
3561
|
|
|
5988
3562
|
if (issue.action_uuid) {
|
|
@@ -5998,519 +3572,52 @@ export class Editor extends RapidElement {
|
|
|
5998
3572
|
}
|
|
5999
3573
|
}
|
|
6000
3574
|
|
|
6001
|
-
|
|
6002
|
-
if (!this.revisionsWindowHidden) {
|
|
6003
|
-
this.handleRevisionsWindowClosed();
|
|
6004
|
-
return;
|
|
6005
|
-
}
|
|
6006
|
-
this.closeOpenWindows();
|
|
6007
|
-
this.fetchRevisions();
|
|
6008
|
-
this.revisionsWindowHidden = false;
|
|
6009
|
-
}
|
|
6010
|
-
|
|
6011
|
-
private handleRevisionsWindowClosed(): void {
|
|
6012
|
-
this.resetRevisionsScroll();
|
|
6013
|
-
this.revisionsWindowHidden = true;
|
|
6014
|
-
if (this.viewingRevision) {
|
|
6015
|
-
this.handleCancelRevisionView();
|
|
6016
|
-
}
|
|
6017
|
-
}
|
|
6018
|
-
|
|
6019
|
-
private resetRevisionsScroll() {
|
|
6020
|
-
const list =
|
|
6021
|
-
this.querySelector('#revisions-window').shadowRoot?.querySelector(
|
|
6022
|
-
'.body'
|
|
6023
|
-
);
|
|
6024
|
-
if (list) {
|
|
6025
|
-
list.scrollTop = 0;
|
|
6026
|
-
}
|
|
6027
|
-
}
|
|
6028
|
-
|
|
6029
|
-
private async fetchRevisions() {
|
|
6030
|
-
this.isLoadingRevisions = true;
|
|
6031
|
-
try {
|
|
6032
|
-
const results = await fetchResults(
|
|
6033
|
-
`/flow/revisions/${this.flow}/?version=${FLOW_SPEC_VERSION}`
|
|
6034
|
-
);
|
|
6035
|
-
this.revisions = results.slice(1);
|
|
6036
|
-
} catch (e) {
|
|
6037
|
-
console.error('Error fetching revisions', e);
|
|
6038
|
-
} finally {
|
|
6039
|
-
this.isLoadingRevisions = false;
|
|
6040
|
-
}
|
|
6041
|
-
}
|
|
6042
|
-
|
|
6043
|
-
private async handleRevisionClick(revision: Revision) {
|
|
6044
|
-
if (this.viewingRevision?.id === revision.id) {
|
|
6045
|
-
return;
|
|
6046
|
-
}
|
|
6047
|
-
|
|
6048
|
-
if (!this.viewingRevision) {
|
|
6049
|
-
// Save current state first
|
|
6050
|
-
this.preRevertState = {
|
|
6051
|
-
definition: this.definition,
|
|
6052
|
-
dirtyDate: this.dirtyDate
|
|
6053
|
-
};
|
|
6054
|
-
this.revisionsBrowseLanguageCode = this.languageCode;
|
|
6055
|
-
}
|
|
3575
|
+
// --- Revisions window event handlers ---
|
|
6056
3576
|
|
|
6057
|
-
|
|
3577
|
+
private handleRevisionViewed(): void {
|
|
3578
|
+
this.viewingRevision = true;
|
|
6058
3579
|
this.closeFlowSearch();
|
|
6059
|
-
this.isLoadingRevisions = true;
|
|
6060
3580
|
this.plumber?.reset();
|
|
6061
|
-
|
|
6062
|
-
try {
|
|
6063
|
-
await getStore()
|
|
6064
|
-
.getState()
|
|
6065
|
-
.fetchRevision(`/flow/revisions/${this.flow}`, revision.id.toString());
|
|
6066
|
-
if (this.revisionsBrowseLanguageCode) {
|
|
6067
|
-
this.handleLanguageChange(this.revisionsBrowseLanguageCode);
|
|
6068
|
-
}
|
|
6069
|
-
} catch (e) {
|
|
6070
|
-
console.error('Error fetching revision details', e);
|
|
6071
|
-
this.handleCancelRevisionView();
|
|
6072
|
-
} finally {
|
|
6073
|
-
this.isLoadingRevisions = false;
|
|
6074
|
-
}
|
|
6075
3581
|
}
|
|
6076
3582
|
|
|
6077
|
-
private
|
|
3583
|
+
private handleRevisionCancelled(): void {
|
|
3584
|
+
this.viewingRevision = false;
|
|
6078
3585
|
this.plumber?.reset();
|
|
6079
|
-
const preservedLanguageCode =
|
|
6080
|
-
this.revisionsBrowseLanguageCode || this.languageCode;
|
|
6081
|
-
if (this.preRevertState) {
|
|
6082
|
-
const currentInfo = getStore().getState().flowInfo;
|
|
6083
|
-
getStore().getState().setFlowContents({
|
|
6084
|
-
definition: this.preRevertState.definition,
|
|
6085
|
-
info: currentInfo
|
|
6086
|
-
});
|
|
6087
|
-
if (this.preRevertState.dirtyDate) {
|
|
6088
|
-
getStore().getState().setDirtyDate(this.preRevertState.dirtyDate);
|
|
6089
|
-
}
|
|
6090
|
-
if (preservedLanguageCode) {
|
|
6091
|
-
this.handleLanguageChange(preservedLanguageCode);
|
|
6092
|
-
}
|
|
6093
|
-
} else {
|
|
6094
|
-
// Fallback if no pre-revert definition
|
|
6095
|
-
getStore()
|
|
6096
|
-
.getState()
|
|
6097
|
-
.fetchRevision(`/flow/revisions/${this.flow}`)
|
|
6098
|
-
.finally(() => {
|
|
6099
|
-
if (preservedLanguageCode) {
|
|
6100
|
-
this.handleLanguageChange(preservedLanguageCode);
|
|
6101
|
-
}
|
|
6102
|
-
});
|
|
6103
|
-
}
|
|
6104
|
-
|
|
6105
|
-
this.viewingRevision = null;
|
|
6106
|
-
this.preRevertState = null;
|
|
6107
|
-
this.revisionsBrowseLanguageCode = null;
|
|
6108
3586
|
}
|
|
6109
3587
|
|
|
6110
|
-
private
|
|
6111
|
-
|
|
6112
|
-
this.plumber?.reset();
|
|
6113
|
-
const preservedLanguageCode =
|
|
6114
|
-
this.revisionsBrowseLanguageCode || this.languageCode;
|
|
6115
|
-
|
|
6116
|
-
// Use the content of the viewing revision (this.definition)
|
|
6117
|
-
// but the revision number of the current head (preRevertState)
|
|
6118
|
-
// so the server accepts it as a valid update
|
|
6119
|
-
const definitionToSave = {
|
|
6120
|
-
...this.definition,
|
|
6121
|
-
revision: this.preRevertState.definition.revision
|
|
6122
|
-
};
|
|
6123
|
-
|
|
6124
|
-
await this.saveChanges(definitionToSave);
|
|
6125
|
-
this.viewingRevision = null;
|
|
6126
|
-
this.preRevertState = null;
|
|
3588
|
+
private handleRevisionsClosed(): void {
|
|
3589
|
+
this.viewingRevision = false;
|
|
6127
3590
|
this.revisionsWindowHidden = true;
|
|
3591
|
+
}
|
|
6128
3592
|
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
3593
|
+
private async handleRevisionReverted(e: CustomEvent): Promise<void> {
|
|
3594
|
+
const { definition, languageCode } = e.detail;
|
|
3595
|
+
this.viewingRevision = false;
|
|
3596
|
+
this.revisionsWindowHidden = true;
|
|
3597
|
+
this.plumber?.reset();
|
|
6133
3598
|
|
|
6134
|
-
|
|
6135
|
-
this.fetchRevisions();
|
|
3599
|
+
await this.saveChanges(definition);
|
|
6136
3600
|
|
|
6137
|
-
// Fetch the latest version of the flow to ensure the store is up to date
|
|
6138
3601
|
getStore()
|
|
6139
3602
|
.getState()
|
|
6140
3603
|
.fetchRevision(`/flow/revisions/${this.flow}`)
|
|
6141
3604
|
.finally(() => {
|
|
6142
|
-
if (
|
|
6143
|
-
this.handleLanguageChange(
|
|
3605
|
+
if (languageCode) {
|
|
3606
|
+
this.handleLanguageChange(languageCode);
|
|
6144
3607
|
}
|
|
6145
3608
|
});
|
|
6146
|
-
this.revisionsBrowseLanguageCode = null;
|
|
6147
|
-
}
|
|
6148
|
-
|
|
6149
|
-
private renderIssuesTab(): TemplateResult | string {
|
|
6150
|
-
if (!this.flowIssues?.length) return '';
|
|
6151
|
-
return html`
|
|
6152
|
-
<temba-floating-tab
|
|
6153
|
-
id="issues-tab"
|
|
6154
|
-
icon="alert_warning"
|
|
6155
|
-
label="Flow Issues"
|
|
6156
|
-
color="tomato"
|
|
6157
|
-
order="2"
|
|
6158
|
-
.active=${!this.issuesWindowHidden}
|
|
6159
|
-
@temba-button-clicked=${this.handleIssuesTabClick}
|
|
6160
|
-
></temba-floating-tab>
|
|
6161
|
-
`;
|
|
6162
|
-
}
|
|
6163
|
-
|
|
6164
|
-
private renderIssuesWindow(): TemplateResult | string {
|
|
6165
|
-
if (!this.flowIssues?.length) return '';
|
|
6166
|
-
return html`
|
|
6167
|
-
<temba-floating-window
|
|
6168
|
-
id="issues-window"
|
|
6169
|
-
name="issues"
|
|
6170
|
-
header="Flow Issues"
|
|
6171
|
-
icon="alert_warning"
|
|
6172
|
-
.width=${360}
|
|
6173
|
-
.maxHeight=${600}
|
|
6174
|
-
.top=${120}
|
|
6175
|
-
color="tomato"
|
|
6176
|
-
.hidden=${this.issuesWindowHidden}
|
|
6177
|
-
@temba-dialog-hidden=${this.handleIssuesWindowClosed}
|
|
6178
|
-
>
|
|
6179
|
-
<div style="display:flex; flex-direction:column; gap:2px;">
|
|
6180
|
-
${this.flowIssues.map(
|
|
6181
|
-
(issue) => html`
|
|
6182
|
-
<div
|
|
6183
|
-
class="issue-list-item"
|
|
6184
|
-
@click=${() => this.handleIssueItemClick(issue)}
|
|
6185
|
-
>
|
|
6186
|
-
<temba-icon name="alert_warning" size="1.2"></temba-icon>
|
|
6187
|
-
<span>${formatIssueMessage(issue)}</span>
|
|
6188
|
-
</div>
|
|
6189
|
-
`
|
|
6190
|
-
)}
|
|
6191
|
-
</div>
|
|
6192
|
-
</temba-floating-window>
|
|
6193
|
-
`;
|
|
6194
|
-
}
|
|
6195
|
-
|
|
6196
|
-
private renderRevisionsWindow(): TemplateResult | string {
|
|
6197
|
-
return html`
|
|
6198
|
-
<temba-floating-window
|
|
6199
|
-
id="revisions-window"
|
|
6200
|
-
name="revisions"
|
|
6201
|
-
header="Revisions"
|
|
6202
|
-
icon="revisions"
|
|
6203
|
-
.width=${240}
|
|
6204
|
-
.maxHeight=${400}
|
|
6205
|
-
.top=${120}
|
|
6206
|
-
color="rgb(142, 94, 167)"
|
|
6207
|
-
.saving=${this.isSaving}
|
|
6208
|
-
.hidden=${this.revisionsWindowHidden}
|
|
6209
|
-
@temba-dialog-hidden=${this.handleRevisionsWindowClosed}
|
|
6210
|
-
>
|
|
6211
|
-
<div class="localization-window-content">
|
|
6212
|
-
<div
|
|
6213
|
-
class="revisions-list"
|
|
6214
|
-
style="display:flex; flex-direction:column; gap:8px; overflow-y:auto; padding-bottom:10px;"
|
|
6215
|
-
>
|
|
6216
|
-
${this.isLoadingRevisions && !this.revisions.length
|
|
6217
|
-
? html`<temba-loading></temba-loading>`
|
|
6218
|
-
: this.revisions.map((rev) => {
|
|
6219
|
-
const isSelected = this.viewingRevision?.id === rev.id;
|
|
6220
|
-
return html`
|
|
6221
|
-
<div
|
|
6222
|
-
class="revision-item ${isSelected ? 'selected' : ''}"
|
|
6223
|
-
style="padding:8px; border-radius:4px; cursor:pointer; background:${
|
|
6224
|
-
isSelected
|
|
6225
|
-
? '#f0f6ff' // Light blue bg for selected
|
|
6226
|
-
: '#f9fafb'
|
|
6227
|
-
}; border:1px solid ${
|
|
6228
|
-
isSelected ? '#a4cafe' : '#e5e7eb'
|
|
6229
|
-
}; transition: all 0.2s ease;"
|
|
6230
|
-
@click=${() => this.handleRevisionClick(rev)}
|
|
6231
|
-
>
|
|
6232
|
-
<div
|
|
6233
|
-
style="display:flex; justify-content:space-between; align-items:center;"
|
|
6234
|
-
>
|
|
6235
|
-
<div
|
|
6236
|
-
class="revision-header"
|
|
6237
|
-
style="margin-bottom: 2px;"
|
|
6238
|
-
>
|
|
6239
|
-
<div
|
|
6240
|
-
style="font-weight:600; font-size:13px; color:#111827;"
|
|
6241
|
-
>
|
|
6242
|
-
<temba-date value=${
|
|
6243
|
-
rev.created_on
|
|
6244
|
-
} display="duration"></temba-date>
|
|
6245
|
-
|
|
6246
|
-
</div>
|
|
6247
|
-
<div style="font-size:11px; color:#6b7280;">
|
|
6248
|
-
${rev.user.name || rev.user.username}
|
|
6249
|
-
</div>
|
|
6250
|
-
</div>
|
|
6251
|
-
${
|
|
6252
|
-
isSelected
|
|
6253
|
-
? html`<button
|
|
6254
|
-
class="revert-button"
|
|
6255
|
-
@click=${this.handleRevertClick}
|
|
6256
|
-
>
|
|
6257
|
-
Revert
|
|
6258
|
-
</button>`
|
|
6259
|
-
: html``
|
|
6260
|
-
}
|
|
6261
|
-
|
|
6262
|
-
</button>
|
|
6263
|
-
</div>
|
|
6264
|
-
|
|
6265
|
-
${
|
|
6266
|
-
rev.comment
|
|
6267
|
-
? html`<div
|
|
6268
|
-
style="font-size:12px; color:#4b5563; margin-top:4px;"
|
|
6269
|
-
>
|
|
6270
|
-
${rev.comment}
|
|
6271
|
-
</div>`
|
|
6272
|
-
: ''
|
|
6273
|
-
}
|
|
6274
|
-
|
|
6275
|
-
</div>
|
|
6276
|
-
`;
|
|
6277
|
-
})}
|
|
6278
|
-
</div>
|
|
6279
|
-
</div>
|
|
6280
|
-
</temba-floating-window>
|
|
6281
|
-
`;
|
|
6282
|
-
}
|
|
6283
|
-
|
|
6284
|
-
private renderLocalizationWindow(): TemplateResult | string {
|
|
6285
|
-
const languages = this.getLocalizationLanguages();
|
|
6286
|
-
if (!languages.length) {
|
|
6287
|
-
return html``;
|
|
6288
|
-
}
|
|
6289
|
-
|
|
6290
|
-
const baseLanguage = this.definition?.language;
|
|
6291
|
-
const isBaseSelected =
|
|
6292
|
-
!this.languageCode ||
|
|
6293
|
-
this.languageCode === baseLanguage ||
|
|
6294
|
-
!languages.some((lang) => lang.code === this.languageCode);
|
|
6295
|
-
const activeLanguage = !isBaseSelected
|
|
6296
|
-
? languages.find((lang) => lang.code === this.languageCode)
|
|
6297
|
-
: null;
|
|
6298
|
-
const progress = this.getLocalizationProgress(
|
|
6299
|
-
isBaseSelected ? '' : this.languageCode
|
|
6300
|
-
);
|
|
6301
|
-
const settingsPanelId = 'translation-settings-panel';
|
|
6302
|
-
const remainingTranslations = Math.max(
|
|
6303
|
-
progress.total - progress.localized,
|
|
6304
|
-
0
|
|
6305
|
-
);
|
|
6306
|
-
const hasTranslations = progress.total > 0;
|
|
6307
|
-
const hasPendingTranslations = remainingTranslations > 0;
|
|
6308
|
-
|
|
6309
|
-
return html`
|
|
6310
|
-
<temba-floating-window
|
|
6311
|
-
id="localization-window"
|
|
6312
|
-
name="localization"
|
|
6313
|
-
header="Translations"
|
|
6314
|
-
icon="language"
|
|
6315
|
-
.width=${360}
|
|
6316
|
-
.maxHeight=${600}
|
|
6317
|
-
.top=${120}
|
|
6318
|
-
color="#5b7ea6"
|
|
6319
|
-
.hidden=${this.localizationWindowHidden}
|
|
6320
|
-
@temba-dialog-hidden=${this.handleLocalizationWindowClosed}
|
|
6321
|
-
>
|
|
6322
|
-
<div class="localization-window-content">
|
|
6323
|
-
<div class="localization-header">
|
|
6324
|
-
Select a language to view or translate your flow content.
|
|
6325
|
-
</div>
|
|
6326
|
-
<div class="localization-language-row">
|
|
6327
|
-
<temba-select
|
|
6328
|
-
flavor="small"
|
|
6329
|
-
class="localization-language-select"
|
|
6330
|
-
placeholder="Select a language"
|
|
6331
|
-
?clearable=${true}
|
|
6332
|
-
.values=${activeLanguage
|
|
6333
|
-
? [{ name: activeLanguage.name, value: activeLanguage.code }]
|
|
6334
|
-
: []}
|
|
6335
|
-
@change=${this.handleLocalizationLanguageSelectChange}
|
|
6336
|
-
>
|
|
6337
|
-
${languages.map(
|
|
6338
|
-
(lang) =>
|
|
6339
|
-
html`<temba-option
|
|
6340
|
-
value="${lang.code}"
|
|
6341
|
-
name="${lang.name}"
|
|
6342
|
-
></temba-option>`
|
|
6343
|
-
)}
|
|
6344
|
-
</temba-select>
|
|
6345
|
-
${''/* auto translate button hidden pending backend changes */}
|
|
6346
|
-
</div>
|
|
6347
|
-
<div
|
|
6348
|
-
class="localization-progress ${isBaseSelected ? 'disabled' : ''}"
|
|
6349
|
-
>
|
|
6350
|
-
<div class="localization-progress-summary">
|
|
6351
|
-
${isBaseSelected
|
|
6352
|
-
? html`<span
|
|
6353
|
-
>Select a language to see translation progress.</span
|
|
6354
|
-
>`
|
|
6355
|
-
: !hasTranslations
|
|
6356
|
-
? // prettier-ignore
|
|
6357
|
-
html`<span>Add content or enable more options to start translating.</span>`
|
|
6358
|
-
: hasPendingTranslations
|
|
6359
|
-
? // prettier-ignore
|
|
6360
|
-
html`<span>${progress.localized} of ${progress.total} items translated</span>`
|
|
6361
|
-
: html`<span>All items are translated.</span>`}
|
|
6362
|
-
</div>
|
|
6363
|
-
${''/* auto translate error hidden pending backend changes */}
|
|
6364
|
-
<div class="localization-progress-bar-row">
|
|
6365
|
-
<div
|
|
6366
|
-
class="localization-progress-trigger"
|
|
6367
|
-
role="button"
|
|
6368
|
-
tabindex="0"
|
|
6369
|
-
aria-expanded="${this.translationSettingsExpanded}"
|
|
6370
|
-
aria-controls="${settingsPanelId}"
|
|
6371
|
-
@click=${this.handleLocalizationProgressToggleClick}
|
|
6372
|
-
@keydown=${this.handleLocalizationProgressToggleKeydown}
|
|
6373
|
-
>
|
|
6374
|
-
<temba-progress
|
|
6375
|
-
.current=${progress.localized}
|
|
6376
|
-
.total=${Math.max(progress.total, 1)}
|
|
6377
|
-
.animated=${false}
|
|
6378
|
-
></temba-progress>
|
|
6379
|
-
</div>
|
|
6380
|
-
<div
|
|
6381
|
-
class="translation-settings-toggle"
|
|
6382
|
-
@click=${this.toggleTranslationSettings}
|
|
6383
|
-
aria-expanded="${this.translationSettingsExpanded}"
|
|
6384
|
-
aria-controls="${settingsPanelId}"
|
|
6385
|
-
>
|
|
6386
|
-
<span
|
|
6387
|
-
class="translation-settings-arrow ${this
|
|
6388
|
-
.translationSettingsExpanded
|
|
6389
|
-
? 'expanded'
|
|
6390
|
-
: ''}"
|
|
6391
|
-
></span>
|
|
6392
|
-
</div>
|
|
6393
|
-
</div>
|
|
6394
|
-
${this.translationSettingsExpanded
|
|
6395
|
-
? html`<div
|
|
6396
|
-
id="${settingsPanelId}"
|
|
6397
|
-
class="translation-settings"
|
|
6398
|
-
>
|
|
6399
|
-
</div>`
|
|
6400
|
-
: ''}
|
|
6401
|
-
</div>
|
|
6402
|
-
</div>
|
|
6403
|
-
</temba-floating-window>
|
|
6404
|
-
`;
|
|
6405
|
-
}
|
|
6406
|
-
|
|
6407
|
-
private renderAutoTranslateDialog(): TemplateResult | string {
|
|
6408
|
-
if (!this.autoTranslateDialogOpen) {
|
|
6409
|
-
return html``;
|
|
6410
|
-
}
|
|
6411
|
-
|
|
6412
|
-
const selectedModel = this.autoTranslateModel
|
|
6413
|
-
? [this.autoTranslateModel]
|
|
6414
|
-
: [];
|
|
6415
|
-
const disableTranslate = !this.autoTranslateModel;
|
|
6416
|
-
|
|
6417
|
-
return html`
|
|
6418
|
-
<temba-dialog
|
|
6419
|
-
header="Auto translate"
|
|
6420
|
-
.open=${this.autoTranslateDialogOpen}
|
|
6421
|
-
primaryButtonName="Translate"
|
|
6422
|
-
cancelButtonName="Cancel"
|
|
6423
|
-
size="small"
|
|
6424
|
-
.disabled=${disableTranslate}
|
|
6425
|
-
@temba-button-clicked=${this.handleAutoTranslateDialogButton}
|
|
6426
|
-
>
|
|
6427
|
-
<div class="auto-translate-dialog-content">
|
|
6428
|
-
<p>
|
|
6429
|
-
We'll send any untranslated text to the selected AI model and save
|
|
6430
|
-
the responses automatically.
|
|
6431
|
-
</p>
|
|
6432
|
-
<div class="auto-translate-models">
|
|
6433
|
-
<temba-select
|
|
6434
|
-
class="auto-translate-model-select"
|
|
6435
|
-
endpoint="${AUTO_TRANSLATE_MODELS_ENDPOINT}"
|
|
6436
|
-
.valueKey=${'uuid'}
|
|
6437
|
-
.values=${selectedModel}
|
|
6438
|
-
?searchable=${true}
|
|
6439
|
-
?clearable=${true}
|
|
6440
|
-
placeholder="Select an AI model"
|
|
6441
|
-
@change=${this.handleAutoTranslateModelChange}
|
|
6442
|
-
></temba-select>
|
|
6443
|
-
</div>
|
|
6444
|
-
<p>Only text without translations will be sent.</p>
|
|
6445
|
-
${this.autoTranslateError
|
|
6446
|
-
? html`<div class="auto-translate-error">
|
|
6447
|
-
${this.autoTranslateError}
|
|
6448
|
-
</div>`
|
|
6449
|
-
: ''}
|
|
6450
|
-
</div>
|
|
6451
|
-
</temba-dialog>
|
|
6452
|
-
`;
|
|
6453
3609
|
}
|
|
6454
3610
|
|
|
6455
3611
|
private renderToolbarElement(): TemplateResult {
|
|
6456
|
-
const languages = this.getLocalizationLanguages();
|
|
6457
|
-
const availableLanguages = this.getAvailableLanguages();
|
|
6458
|
-
const baseLanguage = this.definition?.language;
|
|
6459
|
-
const baseLanguageName =
|
|
6460
|
-
availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
|
|
6461
|
-
baseLanguage ||
|
|
6462
|
-
'Primary language';
|
|
6463
|
-
const isBaseSelected =
|
|
6464
|
-
!this.languageCode ||
|
|
6465
|
-
this.languageCode === baseLanguage ||
|
|
6466
|
-
!languages.some((lang) => lang.code === this.languageCode);
|
|
6467
|
-
const activeLanguage = !isBaseSelected
|
|
6468
|
-
? languages.find((lang) => lang.code === this.languageCode)
|
|
6469
|
-
: null;
|
|
6470
|
-
const currentLanguage = activeLanguage || {
|
|
6471
|
-
code: baseLanguage || '',
|
|
6472
|
-
name: baseLanguageName
|
|
6473
|
-
};
|
|
6474
|
-
const progress = this.getLocalizationProgress(
|
|
6475
|
-
isBaseSelected ? '' : this.languageCode
|
|
6476
|
-
);
|
|
6477
|
-
const percent = Math.round(
|
|
6478
|
-
(progress.localized / Math.max(progress.total, 1)) * 100
|
|
6479
|
-
);
|
|
6480
|
-
const languageOptions = [
|
|
6481
|
-
{
|
|
6482
|
-
name: baseLanguageName,
|
|
6483
|
-
value: PRIMARY_LANGUAGE_OPTION_VALUE
|
|
6484
|
-
},
|
|
6485
|
-
...languages.map((lang) => {
|
|
6486
|
-
const localizationProgress = this.getLocalizationProgress(lang.code);
|
|
6487
|
-
const localizationPercent = Math.round(
|
|
6488
|
-
(localizationProgress.localized /
|
|
6489
|
-
Math.max(localizationProgress.total, 1)) *
|
|
6490
|
-
100
|
|
6491
|
-
);
|
|
6492
|
-
return {
|
|
6493
|
-
name: lang.name,
|
|
6494
|
-
value: lang.code,
|
|
6495
|
-
percent: localizationPercent
|
|
6496
|
-
};
|
|
6497
|
-
})
|
|
6498
|
-
];
|
|
6499
|
-
|
|
6500
3612
|
return html`
|
|
6501
3613
|
<temba-editor-toolbar
|
|
6502
3614
|
?message-view=${this.showMessageTable}
|
|
6503
3615
|
.zoom=${this.zoom}
|
|
6504
|
-
?zoom-initialized=${this.
|
|
6505
|
-
?zoom-fitted=${this.
|
|
3616
|
+
?zoom-initialized=${this.zoomManager.isZoomInitialized}
|
|
3617
|
+
?zoom-fitted=${this.zoomManager.isZoomFitted}
|
|
6506
3618
|
?revisions-active=${!this.revisionsWindowHidden}
|
|
6507
3619
|
?is-saving=${this.isSaving}
|
|
6508
|
-
?search-disabled=${
|
|
6509
|
-
.languageOptions=${languageOptions}
|
|
6510
|
-
current-language-name=${currentLanguage.name}
|
|
6511
|
-
?is-base-language=${isBaseSelected}
|
|
6512
|
-
.languagePercent=${percent}
|
|
6513
|
-
?show-localization-tools=${Boolean(activeLanguage)}
|
|
3620
|
+
?search-disabled=${this.getRevisionsWindow()?.isViewingRevision ?? false}
|
|
6514
3621
|
@temba-button-clicked=${this.handleToolbarAction}
|
|
6515
3622
|
></temba-editor-toolbar>
|
|
6516
3623
|
`;
|
|
@@ -6523,30 +3630,29 @@ export class Editor extends RapidElement {
|
|
|
6523
3630
|
this.showMessageTable = detail.view === 'table';
|
|
6524
3631
|
break;
|
|
6525
3632
|
case 'zoom-in':
|
|
6526
|
-
this.zoomIn();
|
|
3633
|
+
this.zoomManager.zoomIn();
|
|
6527
3634
|
break;
|
|
6528
3635
|
case 'zoom-out':
|
|
6529
|
-
this.zoomOut();
|
|
3636
|
+
this.zoomManager.zoomOut();
|
|
6530
3637
|
break;
|
|
6531
3638
|
case 'zoom-to-fit':
|
|
6532
|
-
this.zoomToFit();
|
|
3639
|
+
this.zoomManager.zoomToFit();
|
|
6533
3640
|
break;
|
|
6534
3641
|
case 'zoom-to-full':
|
|
6535
|
-
this.zoomToFull();
|
|
3642
|
+
this.zoomManager.zoomToFull();
|
|
6536
3643
|
break;
|
|
6537
3644
|
case 'revisions':
|
|
6538
|
-
this.
|
|
3645
|
+
if (!this.revisionsWindowHidden) {
|
|
3646
|
+
this.getRevisionsWindow()?.close();
|
|
3647
|
+
this.revisionsWindowHidden = true;
|
|
3648
|
+
} else {
|
|
3649
|
+
this.closeOpenWindows();
|
|
3650
|
+
this.revisionsWindowHidden = false;
|
|
3651
|
+
}
|
|
6539
3652
|
break;
|
|
6540
3653
|
case 'search':
|
|
6541
3654
|
this.openFlowSearch();
|
|
6542
3655
|
break;
|
|
6543
|
-
case 'language-change':
|
|
6544
|
-
if (detail.isPrimary) {
|
|
6545
|
-
this.handleLanguageChange(this.definition?.language || '');
|
|
6546
|
-
} else if (detail.languageCode) {
|
|
6547
|
-
this.handleLanguageChange(detail.languageCode);
|
|
6548
|
-
}
|
|
6549
|
-
break;
|
|
6550
3656
|
}
|
|
6551
3657
|
}
|
|
6552
3658
|
|
|
@@ -6601,9 +3707,7 @@ export class Editor extends RapidElement {
|
|
|
6601
3707
|
return;
|
|
6602
3708
|
}
|
|
6603
3709
|
|
|
6604
|
-
const node = this.definition.nodes.find(
|
|
6605
|
-
(n) => n.uuid === result.nodeUuid
|
|
6606
|
-
);
|
|
3710
|
+
const node = this.definition.nodes.find((n) => n.uuid === result.nodeUuid);
|
|
6607
3711
|
if (!node) return;
|
|
6608
3712
|
|
|
6609
3713
|
const nodeUI = this.definition._ui?.nodes[result.nodeUuid];
|
|
@@ -6680,8 +3784,8 @@ export class Editor extends RapidElement {
|
|
|
6680
3784
|
}
|
|
6681
3785
|
}
|
|
6682
3786
|
|
|
6683
|
-
|
|
6684
|
-
return this.viewingRevision
|
|
3787
|
+
public isReadOnly(): boolean {
|
|
3788
|
+
return this.viewingRevision || this.isTranslating;
|
|
6685
3789
|
}
|
|
6686
3790
|
|
|
6687
3791
|
public render(): TemplateResult {
|
|
@@ -6699,142 +3803,166 @@ export class Editor extends RapidElement {
|
|
|
6699
3803
|
const hasCorruptedUI =
|
|
6700
3804
|
this.definition &&
|
|
6701
3805
|
this.definition.nodes.length > 0 &&
|
|
6702
|
-
this.definition.nodes.some(
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
3806
|
+
this.definition.nodes.some((n) => !this.definition._ui?.nodes[n.uuid]);
|
|
3807
|
+
|
|
3808
|
+
return html`${style}
|
|
3809
|
+
<temba-issues-window
|
|
3810
|
+
.issues=${this.flowIssues}
|
|
3811
|
+
?hidden=${this.issuesWindowHidden}
|
|
3812
|
+
@temba-issue-selected=${this.handleIssueSelected}
|
|
3813
|
+
@temba-issues-closed=${() => (this.issuesWindowHidden = true)}
|
|
3814
|
+
></temba-issues-window>
|
|
3815
|
+
<temba-revisions-window
|
|
3816
|
+
.flow=${this.flow}
|
|
3817
|
+
?hidden=${this.revisionsWindowHidden}
|
|
3818
|
+
?saving=${this.isSaving}
|
|
3819
|
+
@temba-revision-viewed=${this.handleRevisionViewed}
|
|
3820
|
+
@temba-revision-cancelled=${this.handleRevisionCancelled}
|
|
3821
|
+
@temba-revision-reverted=${this.handleRevisionReverted}
|
|
3822
|
+
@temba-revisions-closed=${this.handleRevisionsClosed}
|
|
3823
|
+
></temba-revisions-window>
|
|
6708
3824
|
<div id="editor-container">
|
|
6709
3825
|
${this.renderToolbarElement()}
|
|
6710
3826
|
<div id="editor">
|
|
6711
3827
|
${this.showMessageTable
|
|
6712
3828
|
? html`<temba-message-table></temba-message-table>`
|
|
6713
3829
|
: html`
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
3830
|
+
${hasCorruptedUI
|
|
3831
|
+
? html`<div class="empty-flow">
|
|
3832
|
+
<div class="empty-flow-content">
|
|
3833
|
+
<div class="empty-flow-title">
|
|
3834
|
+
Unable to display this flow
|
|
3835
|
+
</div>
|
|
3836
|
+
<div class="empty-flow-description">
|
|
3837
|
+
This flow's layout data does not match its nodes. It
|
|
3838
|
+
may have been corrupted during an export or migration.
|
|
3839
|
+
Please re-export the flow from the original workspace
|
|
3840
|
+
and try importing it again.
|
|
3841
|
+
</div>
|
|
3842
|
+
</div>
|
|
3843
|
+
</div>`
|
|
3844
|
+
: this.definition &&
|
|
3845
|
+
this.definition.nodes.length === 0 &&
|
|
3846
|
+
!this.isReadOnly()
|
|
3847
|
+
? html`<div class="empty-flow">
|
|
3848
|
+
<div class="empty-flow-content">
|
|
3849
|
+
<div class="empty-flow-title">This flow is empty</div>
|
|
3850
|
+
<div class="empty-flow-description">
|
|
3851
|
+
Get started by adding your first action or split to
|
|
3852
|
+
define how this flow will work.
|
|
3853
|
+
</div>
|
|
3854
|
+
<button
|
|
3855
|
+
class="empty-flow-button"
|
|
3856
|
+
@click=${this.handleEmptyFlowClick}
|
|
3857
|
+
>
|
|
3858
|
+
Add first step
|
|
3859
|
+
</button>
|
|
3860
|
+
</div>
|
|
3861
|
+
</div>`
|
|
3862
|
+
: ''}
|
|
3863
|
+
<div
|
|
3864
|
+
id="grid"
|
|
3865
|
+
class="${this.viewingRevision
|
|
3866
|
+
? 'viewing-revision'
|
|
3867
|
+
: ''}"
|
|
3868
|
+
style="min-width:${100 / this.zoom}%;min-height:${100 /
|
|
3869
|
+
this.zoom}%;width:${this.canvasSize.width}px; height:${this
|
|
3870
|
+
.canvasSize.height}px;transform:scale(${this.zoom})"
|
|
3871
|
+
>
|
|
3872
|
+
<div
|
|
3873
|
+
id="canvas"
|
|
3874
|
+
class="${getClasses({
|
|
3875
|
+
'viewing-revision':
|
|
3876
|
+
this.viewingRevision,
|
|
3877
|
+
'read-only-connections':
|
|
3878
|
+
this.viewingRevision ||
|
|
3879
|
+
this.isTranslating
|
|
3880
|
+
})}"
|
|
3881
|
+
>
|
|
3882
|
+
${this.definition && !hasCorruptedUI
|
|
3883
|
+
? repeat(
|
|
3884
|
+
[...this.definition.nodes].sort((a, b) =>
|
|
3885
|
+
a.uuid.localeCompare(b.uuid)
|
|
3886
|
+
),
|
|
3887
|
+
(node) => node.uuid,
|
|
3888
|
+
(node) => {
|
|
3889
|
+
const nodeUI = this.definition._ui?.nodes[
|
|
3890
|
+
node.uuid
|
|
3891
|
+
] || {
|
|
3892
|
+
position: { left: 0, top: 0 },
|
|
3893
|
+
type: node.router?.wait
|
|
3894
|
+
? 'wait_for_response'
|
|
3895
|
+
: 'execute_actions'
|
|
3896
|
+
};
|
|
3897
|
+
const position = nodeUI.position;
|
|
3898
|
+
|
|
3899
|
+
const dragging =
|
|
3900
|
+
this.isDragging &&
|
|
3901
|
+
this.currentDragItem?.uuid === node.uuid;
|
|
3902
|
+
|
|
3903
|
+
const selected = this.selectedItems.has(node.uuid);
|
|
3904
|
+
|
|
3905
|
+
// first node is the flow start (nodes are sorted by position)
|
|
3906
|
+
const isFlowStart =
|
|
3907
|
+
this.definition.nodes.length > 0 &&
|
|
3908
|
+
this.definition.nodes[0].uuid === node.uuid;
|
|
3909
|
+
|
|
3910
|
+
return html`<temba-flow-node
|
|
3911
|
+
class="draggable ${dragging
|
|
3912
|
+
? 'dragging'
|
|
3913
|
+
: ''} ${selected
|
|
3914
|
+
? 'selected'
|
|
3915
|
+
: ''} ${isFlowStart ? 'flow-start' : ''}"
|
|
3916
|
+
@mousedown=${(e: MouseEvent) =>
|
|
3917
|
+
this.dragManager.handleMouseDown(e)}
|
|
3918
|
+
@touchstart=${(e: TouchEvent) =>
|
|
3919
|
+
this.dragManager.handleItemTouchStart(e)}
|
|
3920
|
+
uuid=${node.uuid}
|
|
3921
|
+
data-node-uuid=${node.uuid}
|
|
3922
|
+
style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
|
|
3923
|
+
.plumber=${this.plumber}
|
|
3924
|
+
.node=${node}
|
|
3925
|
+
.ui=${nodeUI}
|
|
3926
|
+
@temba-node-deleted=${(event) => {
|
|
3927
|
+
this.deleteNodes([event.detail.uuid]);
|
|
3928
|
+
}}
|
|
3929
|
+
></temba-flow-node>`;
|
|
3930
|
+
}
|
|
3931
|
+
)
|
|
3932
|
+
: hasCorruptedUI
|
|
3933
|
+
? ''
|
|
3934
|
+
: html`<temba-loading></temba-loading>`}
|
|
3935
|
+
${repeat(
|
|
3936
|
+
Object.entries(stickies),
|
|
3937
|
+
([uuid]) => uuid,
|
|
3938
|
+
([uuid, sticky]) => {
|
|
3939
|
+
const position = sticky.position || { left: 0, top: 0 };
|
|
3940
|
+
const dragging =
|
|
3941
|
+
this.isDragging &&
|
|
3942
|
+
this.currentDragItem?.uuid === uuid;
|
|
3943
|
+
const selected = this.selectedItems.has(uuid);
|
|
3944
|
+
return html`<temba-sticky-note
|
|
3945
|
+
class="draggable ${dragging
|
|
3946
|
+
? 'dragging'
|
|
3947
|
+
: ''} ${selected ? 'selected' : ''}"
|
|
3948
|
+
@mousedown=${(e: MouseEvent) =>
|
|
3949
|
+
this.dragManager.handleMouseDown(e)}
|
|
3950
|
+
@touchstart=${(e: TouchEvent) =>
|
|
3951
|
+
this.dragManager.handleItemTouchStart(e)}
|
|
3952
|
+
style="left:${position.left}px; top:${position.top}px;"
|
|
3953
|
+
uuid=${uuid}
|
|
3954
|
+
.data=${sticky}
|
|
3955
|
+
.dragging=${dragging}
|
|
3956
|
+
.selected=${selected}
|
|
3957
|
+
></temba-sticky-note>`;
|
|
3958
|
+
}
|
|
3959
|
+
)}
|
|
3960
|
+
${this.dragManager.renderSelectionBox()}
|
|
3961
|
+
${this.renderCanvasDropPreview()}
|
|
3962
|
+
${this.renderConnectionPlaceholder()}
|
|
6725
3963
|
</div>
|
|
6726
3964
|
</div>
|
|
6727
|
-
|
|
6728
|
-
: this.definition &&
|
|
6729
|
-
this.definition.nodes.length === 0 &&
|
|
6730
|
-
!this.isReadOnly()
|
|
6731
|
-
? html`<div class="empty-flow">
|
|
6732
|
-
<div class="empty-flow-content">
|
|
6733
|
-
<div class="empty-flow-title">This flow is empty</div>
|
|
6734
|
-
<div class="empty-flow-description">
|
|
6735
|
-
Get started by adding your first action or split to define
|
|
6736
|
-
how this flow will work.
|
|
6737
|
-
</div>
|
|
6738
|
-
<button
|
|
6739
|
-
class="empty-flow-button"
|
|
6740
|
-
@click=${this.handleEmptyFlowClick}
|
|
6741
|
-
>
|
|
6742
|
-
Add first step
|
|
6743
|
-
</button>
|
|
6744
|
-
</div>
|
|
6745
|
-
</div>`
|
|
6746
|
-
: ''}
|
|
6747
|
-
<div
|
|
6748
|
-
id="grid"
|
|
6749
|
-
class="${this.viewingRevision ? 'viewing-revision' : ''}"
|
|
6750
|
-
style="min-width:${100 / this.zoom}%;min-height:${100 /
|
|
6751
|
-
this.zoom}%;width:${this.canvasSize.width}px; height:${this
|
|
6752
|
-
.canvasSize.height}px;transform:scale(${this.zoom})"
|
|
6753
|
-
>
|
|
6754
|
-
<div
|
|
6755
|
-
id="canvas"
|
|
6756
|
-
class="${getClasses({
|
|
6757
|
-
'viewing-revision': !!this.viewingRevision,
|
|
6758
|
-
'read-only-connections':
|
|
6759
|
-
!!this.viewingRevision || this.isTranslating
|
|
6760
|
-
})}"
|
|
6761
|
-
>
|
|
6762
|
-
${this.definition && !hasCorruptedUI
|
|
6763
|
-
? repeat(
|
|
6764
|
-
[...this.definition.nodes].sort((a, b) =>
|
|
6765
|
-
a.uuid.localeCompare(b.uuid)
|
|
6766
|
-
),
|
|
6767
|
-
(node) => node.uuid,
|
|
6768
|
-
(node) => {
|
|
6769
|
-
const nodeUI = this.definition._ui?.nodes[node.uuid] || {
|
|
6770
|
-
position: { left: 0, top: 0 },
|
|
6771
|
-
type: node.router?.wait
|
|
6772
|
-
? 'wait_for_response'
|
|
6773
|
-
: 'execute_actions'
|
|
6774
|
-
};
|
|
6775
|
-
const position = nodeUI.position;
|
|
6776
|
-
|
|
6777
|
-
const dragging =
|
|
6778
|
-
this.isDragging &&
|
|
6779
|
-
this.currentDragItem?.uuid === node.uuid;
|
|
6780
|
-
|
|
6781
|
-
const selected = this.selectedItems.has(node.uuid);
|
|
6782
|
-
|
|
6783
|
-
// first node is the flow start (nodes are sorted by position)
|
|
6784
|
-
const isFlowStart =
|
|
6785
|
-
this.definition.nodes.length > 0 &&
|
|
6786
|
-
this.definition.nodes[0].uuid === node.uuid;
|
|
6787
|
-
|
|
6788
|
-
return html`<temba-flow-node
|
|
6789
|
-
class="draggable ${dragging
|
|
6790
|
-
? 'dragging'
|
|
6791
|
-
: ''} ${selected ? 'selected' : ''} ${isFlowStart
|
|
6792
|
-
? 'flow-start'
|
|
6793
|
-
: ''}"
|
|
6794
|
-
@mousedown=${this.handleMouseDown.bind(this)}
|
|
6795
|
-
@touchstart=${this.handleItemTouchStart.bind(this)}
|
|
6796
|
-
uuid=${node.uuid}
|
|
6797
|
-
data-node-uuid=${node.uuid}
|
|
6798
|
-
style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
|
|
6799
|
-
.plumber=${this.plumber}
|
|
6800
|
-
.node=${node}
|
|
6801
|
-
.ui=${nodeUI}
|
|
6802
|
-
@temba-node-deleted=${(event) => {
|
|
6803
|
-
this.deleteNodes([event.detail.uuid]);
|
|
6804
|
-
}}
|
|
6805
|
-
></temba-flow-node>`;
|
|
6806
|
-
}
|
|
6807
|
-
)
|
|
6808
|
-
: hasCorruptedUI
|
|
6809
|
-
? ''
|
|
6810
|
-
: html`<temba-loading></temba-loading>`}
|
|
6811
|
-
${repeat(
|
|
6812
|
-
Object.entries(stickies),
|
|
6813
|
-
([uuid]) => uuid,
|
|
6814
|
-
([uuid, sticky]) => {
|
|
6815
|
-
const position = sticky.position || { left: 0, top: 0 };
|
|
6816
|
-
const dragging =
|
|
6817
|
-
this.isDragging && this.currentDragItem?.uuid === uuid;
|
|
6818
|
-
const selected = this.selectedItems.has(uuid);
|
|
6819
|
-
return html`<temba-sticky-note
|
|
6820
|
-
class="draggable ${dragging ? 'dragging' : ''} ${selected
|
|
6821
|
-
? 'selected'
|
|
6822
|
-
: ''}"
|
|
6823
|
-
@mousedown=${this.handleMouseDown.bind(this)}
|
|
6824
|
-
@touchstart=${this.handleItemTouchStart.bind(this)}
|
|
6825
|
-
style="left:${position.left}px; top:${position.top}px;"
|
|
6826
|
-
uuid=${uuid}
|
|
6827
|
-
.data=${sticky}
|
|
6828
|
-
.dragging=${dragging}
|
|
6829
|
-
.selected=${selected}
|
|
6830
|
-
></temba-sticky-note>`;
|
|
6831
|
-
}
|
|
6832
|
-
)}
|
|
6833
|
-
${this.renderSelectionBox()} ${this.renderCanvasDropPreview()}
|
|
6834
|
-
${this.renderConnectionPlaceholder()}
|
|
6835
|
-
</div>
|
|
6836
|
-
</div>
|
|
6837
|
-
`}
|
|
3965
|
+
`}
|
|
6838
3966
|
</div>
|
|
6839
3967
|
${this.renderPendingCard()}
|
|
6840
3968
|
<div class="drag-hint" id="drag-hint">Hold ⇧ to duplicate</div>
|
|
@@ -6869,11 +3997,21 @@ export class Editor extends RapidElement {
|
|
|
6869
3997
|
: ''}
|
|
6870
3998
|
<temba-flow-search
|
|
6871
3999
|
.scope=${this.showMessageTable ? 'table' : 'flow'}
|
|
6872
|
-
.includeCategories=${
|
|
4000
|
+
.includeCategories=${false}
|
|
6873
4001
|
@temba-search-result-selected=${this.handleSearchResultSelected}
|
|
6874
4002
|
></temba-flow-search>
|
|
6875
|
-
${!this.showMessageTable
|
|
6876
|
-
|
|
6877
|
-
|
|
4003
|
+
${!this.showMessageTable && this.flowIssues?.length
|
|
4004
|
+
? html`
|
|
4005
|
+
<temba-floating-tab
|
|
4006
|
+
id="issues-tab"
|
|
4007
|
+
icon="alert_warning"
|
|
4008
|
+
label="Flow Issues"
|
|
4009
|
+
color="tomato"
|
|
4010
|
+
order="2"
|
|
4011
|
+
.active=${!this.issuesWindowHidden}
|
|
4012
|
+
@temba-button-clicked=${() => this.handleIssuesTabClick()}
|
|
4013
|
+
></temba-floating-tab>
|
|
4014
|
+
`
|
|
4015
|
+
: ''} `;
|
|
6878
4016
|
}
|
|
6879
4017
|
}
|