@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.
@@ -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 { FloatingTab } from '../display/FloatingTab';
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
- private plumber: Plumber;
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
- private definition!: FlowDefinition;
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(zustand, (state: AppState) => state.flowInfo?.issues ?? EMPTY_FLOW_ISSUES)
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
- private isDragging = false;
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
- private currentDragItem: DraggableItem | null = null;
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
- private selectedItems: Set<string> = new Set();
236
+ public selectedItems: Set<string> = new Set();
306
237
 
307
238
  @state()
308
- private isSelecting = false;
239
+ public isSelecting = false;
309
240
 
310
241
  @state()
311
- private selectionBox: SelectionBox | null = null;
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
- private targetId: string | null = null;
245
+ public targetId: string | null = null;
325
246
 
326
247
  @state()
327
- private sourceId: string | null = null;
248
+ public sourceId: string | null = null;
328
249
 
329
250
  @state()
330
- private dragFromNodeId: string | null = null;
251
+ public dragFromNodeId: string | null = null;
331
252
 
332
253
  @state()
333
- private originalConnectionTargetId: string | null = null;
254
+ public originalConnectionTargetId: string | null = null;
334
255
 
335
256
  @state()
336
- private isValidTarget = true;
257
+ public isValidTarget = true;
337
258
 
338
259
  // Canvas-relative source exit position (set at drag start)
339
- private connectionSourceX: number | null = null;
340
- private connectionSourceY: number | null = null;
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 revisions: Revision[] = [];
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
- private isSaving = false;
273
+ public isSaving = false;
377
274
 
378
275
  @state()
379
276
  private saveError: string | null = null;
380
277
 
381
278
  @state()
382
- private zoom = 1.0;
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
- private pendingTimer = new PendingChangesTimer(
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
- private copiedItemUuids: string[] = [];
305
+ public copiedItemUuids: string[] = [];
421
306
 
422
307
  /** Save all current canvas positions if not already saved. */
423
- private capturePositionsOnce(): void {
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
- private preRevertState: {
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
- private editingNode: Node | null = null;
342
+ public editingNode: Node | null = null;
465
343
 
466
344
  @state()
467
- private editingNodeUI: NodeUI | null = null;
345
+ public editingNodeUI: NodeUI | null = null;
468
346
 
469
347
  @state()
470
- private editingAction: Action | null = null;
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
- private connectionPlaceholder: {
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.bind(this);
565
- private boundTouchMove = this.handleTouchMove.bind(this);
566
- private boundTouchEnd = this.handleTouchEnd.bind(this);
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 { opacity: 0; }
1309
- to { opacity: 1; }
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.markTouchDevice();
1245
+ this.querySelector('#canvas')?.classList.add('touch-device');
1246
+ this.querySelector('#editor')?.classList.add('touch-device');
1397
1247
  }
1398
- this.updateZoomControlPositioning();
1399
- this.loupeEl = this.querySelector('#loupe') as HTMLElement;
1400
- this.loupeContentEl = this.querySelector('#loupe-content') as HTMLElement;
1401
- this.initLoupe();
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('temba-simulator') as HTMLElement & {
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 (changes.has('showMessageTable') && !this.showMessageTable && this.plumber) {
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.stopAutoScroll();
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
- document.addEventListener('mousemove', this.boundMouseMove);
1971
- document.addEventListener('mouseup', this.boundMouseUp);
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
- // Fallback: on first touch, mark as touch device in case
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 = this.isTranslating && this.hasAnyNodeWithLocalizeCategories();
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
- private showDragHint(): void {
2310
- if (this.isReadOnly()) return;
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
- private handleKeyUp(event: KeyboardEvent): void {
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
- private handleWindowBlur(): void {
2413
- this.querySelector('#canvas')?.classList.remove('shift-held');
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
- private getFlowSettings(): Record<string, any> {
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
- private saveFlowSetting(key: string, value: any): void {
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
- private getFlowSetting<T>(key: string): T | undefined {
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
- // --- Zoom ---
2013
+ private showDeleteConfirmation(): void {
2014
+ const itemCount = this.selectedItems.size;
2015
+ const itemType = itemCount === 1 ? 'item' : 'items';
2472
2016
 
2473
- private setZoom(
2474
- newZoom: number,
2475
- center?: { clientX: number; clientY: number }
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 editor = this.querySelector('#editor') as HTMLElement;
2484
- const oldZoom = this.zoom;
2485
- this.zoom = clamped;
2486
- this.plumber.zoom = clamped;
2487
- this.zoomFitted = false;
2488
- this.saveFlowSetting('zoom', clamped);
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
- requestAnimationFrame(() => {
2499
- editor.scrollLeft = cx * clamped - ox;
2500
- editor.scrollTop = cy * clamped - oy;
2501
- this.plumber.repaintEverything();
2502
- });
2503
- } else {
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
- private zoomIn(): void {
2509
- this.setZoom(this.zoom + 0.05);
2510
- }
2035
+ // Add to document and show
2036
+ document.body.appendChild(dialog);
2037
+ dialog.open = true;
2038
+ this.deleteDialog = dialog;
2511
2039
 
2512
- private zoomOut(): void {
2513
- this.setZoom(this.zoom - 0.05);
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 zoomToFit(): void {
2047
+ private performReflow(): void {
2517
2048
  if (!this.definition || this.definition.nodes.length === 0) return;
2518
2049
 
2519
- const editor = this.querySelector('#editor') as HTMLElement;
2520
- if (!editor) return;
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
- if (minX === Infinity) return;
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
- const contentWidth = maxX - minX;
2559
- const contentHeight = maxY - minY;
2560
- const padding = 40;
2064
+ // Identify start node (first in sorted array)
2065
+ const startNodeUuid = this.definition.nodes[0].uuid;
2561
2066
 
2562
- const availWidth = editor.clientWidth - padding * 2;
2563
- const availHeight = editor.clientHeight - padding * 2;
2564
-
2565
- const scaleX = availWidth / contentWidth;
2566
- const scaleY = availHeight / contentHeight;
2567
- let fitZoom = Math.min(scaleX, scaleY, 1.0);
2568
- fitZoom = Math.max(fitZoom, 0.3);
2569
- fitZoom = Math.round(fitZoom * 20) / 20; // round to nearest 0.05
2570
-
2571
- this.zoom = fitZoom;
2572
- this.plumber.zoom = fitZoom;
2573
- this.zoomFitted = true;
2574
- this.saveFlowSetting('zoom', fitZoom);
2575
-
2576
- // Center of content in canvas coordinates, plus grid/canvas margin offset
2577
- const centerX = (minX + maxX) / 2 + 40;
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
- private checkCollisionsAndReflow(sacredUuids: string[]): void {
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
- private findTargetNodeAt(clientX: number, clientY: number): Element | null {
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
- * Handle touchstart on the canvas element. Mirrors handleGlobalMouseDown
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
- const relativeX = (touch.clientX - canvasRect.left) / this.zoom;
3545
- const relativeY = (touch.clientY - canvasRect.top) / this.zoom;
2447
+ const store = getStore();
2448
+ if (!store) return;
3546
2449
 
3547
- this.selectionBox = {
3548
- startX: relativeX,
3549
- startY: relativeY,
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
- private showContextMenuAt(clientX: number, clientY: number): void {
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.showDragHint();
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.hideDragHint();
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.hideDragHint();
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
- const store = getStore();
5826
- if (!store) {
3411
+ // Clear state
3412
+ this.canvasDropPreview = null;
3413
+ this.actionDragTargetNodeUuid = null;
5827
3414
  return;
5828
3415
  }
5829
3416
 
5830
- updates.forEach(({ uuid, translations }) => {
5831
- const normalized = Object.entries(translations).reduce(
5832
- (acc, [key, value]) => {
5833
- if (!value) {
5834
- return acc;
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
- const bundles = this.buildTranslationBundles();
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
- for (const bundle of bundles) {
5873
- if (!this.autoTranslating) {
5874
- break;
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 untranslated = bundle.translations.filter(
5878
- (translation) => !translation.to || translation.to.trim().length === 0
3436
+ const updatedActions = originalNode.actions.filter(
3437
+ (_a, idx) => idx !== actionIndex
5879
3438
  );
5880
3439
 
5881
- if (untranslated.length === 0) {
5882
- continue;
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
- const updates: LocalizationUpdate[] = [];
5886
-
5887
- for (const translation of untranslated) {
5888
- if (!this.autoTranslating) {
5889
- break;
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
- if (!this.shouldTranslateValue(translation.from)) {
5893
- continue;
5894
- }
3465
+ const newNodeUI: NodeUI = {
3466
+ position,
3467
+ type: 'execute_actions',
3468
+ config: {}
3469
+ };
5895
3470
 
5896
- const cached = this.translationCache.get(translation.from);
5897
- if (cached) {
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
- try {
5906
- const result = await this.requestAutoTranslation(translation.from);
5907
- if (result) {
5908
- this.translationCache.set(translation.from, result);
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
- if (updates.length > 0) {
5924
- this.applyLocalizationUpdates(updates, true);
5925
- }
3479
+ // clear the preview
3480
+ this.canvasDropPreview = null;
3481
+ this.actionDragTargetNodeUuid = null;
5926
3482
 
5927
- if (!this.autoTranslating) {
5928
- break;
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.handleIssuesWindowClosed();
3512
+ this.issuesWindowHidden = true;
5938
3513
  }
5939
3514
  if (!this.revisionsWindowHidden) {
5940
- this.handleRevisionsWindowClosed();
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.handleIssuesWindowClosed();
3526
+ this.issuesWindowHidden = true;
5954
3527
  }
5955
3528
  if (!this.revisionsWindowHidden) {
5956
- this.handleRevisionsWindowClosed();
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.handleIssuesWindowClosed();
3544
+ this.issuesWindowHidden = true;
5966
3545
  return;
5967
3546
  }
5968
3547
  this.closeOpenWindows();
5969
3548
  this.issuesWindowHidden = false;
5970
3549
  }
5971
3550
 
5972
- private handleIssuesWindowClosed(): void {
5973
- this.issuesWindowHidden = true;
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((n) => n.uuid === issue.node_uuid);
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
- private handleRevisionsTabClick(): void {
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
- this.viewingRevision = revision;
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 handleCancelRevisionView() {
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 async handleRevertClick() {
6111
- if (!this.viewingRevision || !this.preRevertState) return;
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
- const revisionsWindow = document.getElementById(
6130
- 'revisions-window'
6131
- ) as FloatingWindow;
6132
- revisionsWindow.handleClose();
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
- // Refresh revisions list to show the new one
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 (preservedLanguageCode) {
6143
- this.handleLanguageChange(preservedLanguageCode);
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.zoomInitialized}
6505
- ?zoom-fitted=${this.zoomFitted}
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=${!!this.viewingRevision}
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.handleRevisionsTabClick();
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
- private isReadOnly(): boolean {
6684
- return this.viewingRevision !== null || this.isTranslating;
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
- (n) => !this.definition._ui?.nodes[n.uuid]
6704
- );
6705
-
6706
- return html`${style} ${this.renderIssuesWindow()}
6707
- ${this.renderRevisionsWindow()} ${this.renderLocalizationWindow()}
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
- ${hasCorruptedUI
6715
- ? html`<div class="empty-flow">
6716
- <div class="empty-flow-content">
6717
- <div class="empty-flow-title">
6718
- Unable to display this flow
6719
- </div>
6720
- <div class="empty-flow-description">
6721
- This flow's layout data does not match its nodes. It may
6722
- have been corrupted during an export or migration. Please
6723
- re-export the flow from the original workspace and try
6724
- importing it again.
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
- </div>`
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=${this.isTranslating && this.hasAnyNodeWithLocalizeCategories()}
4000
+ .includeCategories=${false}
6873
4001
  @temba-search-result-selected=${this.handleSearchResultSelected}
6874
4002
  ></temba-flow-search>
6875
- ${!this.showMessageTable ? html`
6876
- ${this.renderIssuesTab()}
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
  }