@nyaruka/temba-components 0.156.3 → 0.156.5

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.
@@ -31,6 +31,7 @@ import {
31
31
  import { TEMBA_COMPONENTS_VERSION } from '../version';
32
32
  import {
33
33
  formatIssueMessage,
34
+ getLanguageDisplayName,
34
35
  getNodeBounds,
35
36
  calculateReflowPositions,
36
37
  isRightClick,
@@ -70,8 +71,8 @@ import { Dialog } from '../layout/Dialog';
70
71
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
71
72
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
72
73
  import { FloatingWindow } from '../layout/FloatingWindow';
73
- import { Icon } from '../Icons';
74
74
  import { FlowSearch, SearchResult } from './FlowSearch';
75
+ import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
75
76
 
76
77
  export function findNodeForExit(
77
78
  definition: FlowDefinition,
@@ -103,8 +104,9 @@ export interface SelectionBox {
103
104
  }
104
105
 
105
106
  const DRAG_THRESHOLD = 5;
106
- const AUTO_SCROLL_EDGE_ZONE = 100;
107
+ const AUTO_SCROLL_EDGE_ZONE = 150;
107
108
  const AUTO_SCROLL_MAX_SPEED = 15;
109
+ const AUTO_SCROLL_BEYOND_MULTIPLIER = 5;
108
110
 
109
111
  type TranslationType = 'property' | 'category';
110
112
 
@@ -134,7 +136,15 @@ interface LocalizationUpdate {
134
136
  }
135
137
 
136
138
  const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
137
- const PRIMARY_LANGUAGE_OPTION_VALUE = '__primary_language__';
139
+ export type ToolbarAction =
140
+ | { action: 'view-change'; view: 'flow' | 'table' }
141
+ | { action: 'zoom-in' }
142
+ | { action: 'zoom-out' }
143
+ | { action: 'zoom-to-fit' }
144
+ | { action: 'zoom-to-full' }
145
+ | { action: 'revisions' }
146
+ | { action: 'search' }
147
+ | { action: 'language-change'; isPrimary?: boolean; languageCode?: string };
138
148
  const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
139
149
 
140
150
  // How long the pending-changes auto-save countdown runs (in ms).
@@ -268,6 +278,13 @@ export class Editor extends RapidElement {
268
278
  private currentDragIsCopy = false;
269
279
  private dragStartPos = { x: 0, y: 0 };
270
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;
287
+
271
288
  // Public getter for drag state
272
289
  public get dragging(): boolean {
273
290
  return this.isDragging;
@@ -458,9 +475,6 @@ export class Editor extends RapidElement {
458
475
  @property({ type: Boolean, reflect: true, attribute: 'message-view' })
459
476
  showMessageTable = false;
460
477
 
461
- @state()
462
- private showLanguageOptions = false;
463
-
464
478
  @state()
465
479
  private isCreatingNewNode = false;
466
480
 
@@ -486,6 +500,19 @@ export class Editor extends RapidElement {
486
500
  // Track previous target node to clear placeholder when moving between nodes
487
501
  private previousActionDragTargetNodeUuid: string | null = null;
488
502
 
503
+ // Track action external drag state for shift-copy support
504
+ private isActionExternalDrag = false;
505
+ private actionDragIsCopy = false;
506
+ private actionDragLastDetail: {
507
+ action: Action;
508
+ nodeUuid: string;
509
+ actionIndex: number;
510
+ mouseX: number;
511
+ mouseY: number;
512
+ actionHeight: number;
513
+ isLastAction: boolean;
514
+ } | null = null;
515
+
489
516
  // Connection placeholder state for dropping connections on empty canvas
490
517
  @state()
491
518
  private connectionPlaceholder: {
@@ -506,16 +533,8 @@ export class Editor extends RapidElement {
506
533
  private getAvailableLanguages(): Array<{ code: string; name: string }> {
507
534
  // Use languages from workspace if available
508
535
  if (this.workspace?.languages && this.workspace.languages.length > 0) {
509
- const languageNames = new Intl.DisplayNames(['en'], { type: 'language' });
510
536
  return this.workspace.languages
511
- .map((code) => {
512
- try {
513
- const name = languageNames.of(code);
514
- return name ? { code, name } : { code, name: code };
515
- } catch {
516
- return { code, name: code };
517
- }
518
- })
537
+ .map((code) => ({ code, name: getLanguageDisplayName(code) }))
519
538
  .filter((lang) => lang.code && lang.name);
520
539
  }
521
540
 
@@ -539,6 +558,8 @@ export class Editor extends RapidElement {
539
558
  private boundMouseUp = this.handleMouseUp.bind(this);
540
559
  private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
541
560
  private boundKeyDown = this.handleKeyDown.bind(this);
561
+ private boundKeyUp = this.handleKeyUp.bind(this);
562
+ private boundWindowBlur = this.handleWindowBlur.bind(this);
542
563
  private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
543
564
  private boundWheel = this.handleWheel.bind(this);
544
565
  private boundTouchMove = this.handleTouchMove.bind(this);
@@ -557,198 +578,6 @@ export class Editor extends RapidElement {
557
578
  min-height: 0;
558
579
  }
559
580
 
560
- .editor-toolbar {
561
- --toolbar-control-height: 28px;
562
- --toolbar-translation-control-height: 28px;
563
- display: flex;
564
- align-items: center;
565
- padding: 6px 12px;
566
- background: #fff;
567
- border-bottom: 1px solid #e8e8e8;
568
- flex-shrink: 0;
569
- gap: 8px;
570
- }
571
-
572
- .toolbar-left {
573
- display: flex;
574
- align-items: center;
575
- gap: 2px;
576
- }
577
-
578
- .toolbar-right {
579
- display: flex;
580
- align-items: center;
581
- gap: 2px;
582
- margin-left: auto;
583
- }
584
-
585
- .toolbar-btn {
586
- width: var(--toolbar-control-height);
587
- height: var(--toolbar-control-height);
588
- border: none;
589
- background: transparent;
590
- border-radius: var(--curvature);
591
- cursor: pointer;
592
- display: flex;
593
- align-items: center;
594
- justify-content: center;
595
- padding: 0;
596
- color: #888;
597
- font-size: 16px;
598
- line-height: 1;
599
- outline: none;
600
- }
601
-
602
- .toolbar-btn:focus {
603
- outline: none;
604
- }
605
-
606
- .toolbar-btn:focus-visible {
607
- outline: 2px solid #0064c8;
608
- outline-offset: 2px;
609
- }
610
-
611
- .toolbar-btn:hover {
612
- background: rgba(0, 0, 0, 0.06);
613
- color: #555;
614
- }
615
-
616
- .toolbar-btn:disabled {
617
- opacity: 0.3;
618
- cursor: default;
619
- background: transparent;
620
- }
621
-
622
- .toolbar-btn.active {
623
- background: rgba(0, 100, 200, 0.1);
624
- color: #0064c8;
625
- }
626
-
627
- .toolbar-btn.active:hover {
628
- background: rgba(0, 100, 200, 0.15);
629
- }
630
-
631
- .toolbar-tip {
632
- display: flex;
633
- align-items: center;
634
- }
635
-
636
- .toolbar-divider {
637
- width: 1px;
638
- height: 16px;
639
- background: #e0e0e0;
640
- margin: 0 4px;
641
- }
642
-
643
- .toolbar-group {
644
- display: flex;
645
- align-items: center;
646
- gap: 4px;
647
- height: var(--toolbar-control-height);
648
- box-sizing: border-box;
649
- padding: 0 3px;
650
- border: 1px solid #d7dce2;
651
- border-radius: calc(var(--curvature) + 2px);
652
- background: #f7f9fb;
653
- }
654
-
655
- .toolbar-group-divider {
656
- width: 1px;
657
- height: 18px;
658
- background: #d7dce2;
659
- margin: 0 2px;
660
- }
661
-
662
- .toolbar-language {
663
- position: relative;
664
- display: flex;
665
- align-items: center;
666
- }
667
-
668
- .toolbar-language-group {
669
- display: flex;
670
- align-items: center;
671
- gap: 6px;
672
- margin-left: 2px;
673
- }
674
-
675
- .toolbar-zoom-group {
676
- gap: 2px;
677
- }
678
-
679
- .language-pill {
680
- display: flex;
681
- align-items: center;
682
- gap: 6px;
683
- background: #e9eef4;
684
- color: #0064c8;
685
- height: var(--toolbar-translation-control-height);
686
- padding: 0 8px;
687
- border-radius: var(--curvature);
688
- box-sizing: border-box;
689
- font-size: 13px;
690
- font-weight: 400;
691
- white-space: nowrap;
692
- cursor: pointer;
693
- --icon-color: #0064c8;
694
- --icon-size: 16px;
695
- border: none;
696
- outline: none;
697
- }
698
-
699
- .language-pill:hover {
700
- filter: brightness(1.04);
701
- }
702
-
703
- .language-pill.primary {
704
- background: #fff;
705
- border: 1px solid #d7dce2;
706
- }
707
-
708
- .language-pill.complete {
709
- background: #d4f5e0;
710
- color: #1a7f37;
711
- --icon-color: #1a7f37;
712
- }
713
-
714
- .language-pill-caret {
715
- margin-left: 1px;
716
- --icon-color: currentColor;
717
- --icon-size: 12px;
718
- }
719
-
720
- .language-percent {
721
- display: inline-block;
722
- font-size: 12px;
723
- font-weight: 700;
724
- line-height: 1;
725
- color: #0064c8;
726
- white-space: nowrap;
727
- }
728
-
729
- .language-pill.complete .language-percent {
730
- color: #1a7f37;
731
- }
732
-
733
- .toolbar-zoom-level {
734
- font-size: 12px;
735
- min-width: 40px;
736
- text-align: center;
737
- color: #555;
738
- font-weight: 500;
739
- }
740
-
741
- .toolbar-translation {
742
- display: flex;
743
- align-items: center;
744
- gap: 4px;
745
- }
746
-
747
- .toolbar-btn.language-tool {
748
- width: var(--toolbar-translation-control-height);
749
- height: var(--toolbar-translation-control-height);
750
- }
751
-
752
581
  #editor {
753
582
  overflow: scroll;
754
583
  flex: 1;
@@ -816,6 +645,11 @@ export class Editor extends RapidElement {
816
645
  transition: none !important;
817
646
  }
818
647
 
648
+ #canvas.shift-held {
649
+ --shift-held-cursor: copy;
650
+ cursor: copy;
651
+ }
652
+
819
653
  #canvas.viewing-revision {
820
654
  pointer-events: none;
821
655
  }
@@ -1050,6 +884,10 @@ export class Editor extends RapidElement {
1050
884
  border-radius: var(--curvature);
1051
885
  }
1052
886
 
887
+ .draggable.selected.drag-copy {
888
+ outline: none;
889
+ }
890
+
1053
891
  /* Language banner replaced by toolbar language selector */
1054
892
 
1055
893
  .localization-window-content {
@@ -1441,6 +1279,36 @@ export class Editor extends RapidElement {
1441
1279
  opacity: 0.8;
1442
1280
  }
1443
1281
 
1282
+ .drag-hint {
1283
+ position: absolute;
1284
+ bottom: 40px;
1285
+ left: 50%;
1286
+ transform: translateX(-50%);
1287
+ z-index: 100000;
1288
+ pointer-events: none;
1289
+ background: rgba(255, 255, 255, 0.5);
1290
+ backdrop-filter: blur(6px);
1291
+ color: #555;
1292
+ font-size: 18px;
1293
+ font-weight: 300;
1294
+ padding: 10px 32px;
1295
+ border-radius: 10px;
1296
+ border: 1px solid rgba(0, 0, 0, 0.08);
1297
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
1298
+ white-space: nowrap;
1299
+ display: none;
1300
+ }
1301
+
1302
+ .drag-hint.visible {
1303
+ display: block;
1304
+ animation: drag-hint-in 0.15s ease forwards;
1305
+ }
1306
+
1307
+ @keyframes drag-hint-in {
1308
+ from { opacity: 0; }
1309
+ to { opacity: 1; }
1310
+ }
1311
+
1444
1312
  .reflow-card {
1445
1313
  position: absolute;
1446
1314
  top: 16px;
@@ -1562,6 +1430,11 @@ export class Editor extends RapidElement {
1562
1430
  }
1563
1431
 
1564
1432
  private makeConnection(info) {
1433
+ this.stopAutoScroll();
1434
+ this.autoScrollDeltaX = 0;
1435
+ this.autoScrollDeltaY = 0;
1436
+ this.lastPointerPos = null;
1437
+
1565
1438
  if (this.sourceId && this.targetId && this.isValidTarget) {
1566
1439
  // going to the same target, just put it back
1567
1440
  if (info.target.id === this.targetId) {
@@ -2064,6 +1937,8 @@ export class Editor extends RapidElement {
2064
1937
  document.removeEventListener('mouseup', this.boundMouseUp);
2065
1938
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
2066
1939
  document.removeEventListener('keydown', this.boundKeyDown);
1940
+ document.removeEventListener('keyup', this.boundKeyUp);
1941
+ window.removeEventListener('blur', this.boundWindowBlur);
2067
1942
  document.removeEventListener('touchmove', this.boundTouchMove);
2068
1943
  document.removeEventListener('touchend', this.boundTouchEnd);
2069
1944
  document.removeEventListener('touchcancel', this.boundTouchCancel);
@@ -2096,6 +1971,8 @@ export class Editor extends RapidElement {
2096
1971
  document.addEventListener('mouseup', this.boundMouseUp);
2097
1972
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
2098
1973
  document.addEventListener('keydown', this.boundKeyDown);
1974
+ document.addEventListener('keyup', this.boundKeyUp);
1975
+ window.addEventListener('blur', this.boundWindowBlur);
2099
1976
  document.addEventListener('touchmove', this.boundTouchMove, {
2100
1977
  passive: false
2101
1978
  });
@@ -2429,7 +2306,56 @@ export class Editor extends RapidElement {
2429
2306
  }
2430
2307
  }
2431
2308
 
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
+
2432
2330
  private handleKeyDown(event: KeyboardEvent): void {
2331
+ 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
+ // Toggle to copy mode mid-drag (actions)
2347
+ if (this.isActionExternalDrag && !this.actionDragIsCopy) {
2348
+ this.actionDragIsCopy = true;
2349
+ this.hideDragHint();
2350
+ this.showActionOriginal(true);
2351
+ // If this is a last-action drag, now show the canvas preview
2352
+ if (this.actionDragLastDetail?.isLastAction) {
2353
+ this.reprocessActionDrag();
2354
+ }
2355
+ this.requestUpdate();
2356
+ }
2357
+ }
2358
+
2433
2359
  // Cmd/Ctrl+F opens flow search (unless a dialog is already open)
2434
2360
  if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
2435
2361
  event.preventDefault();
@@ -2459,6 +2385,50 @@ export class Editor extends RapidElement {
2459
2385
  }
2460
2386
  }
2461
2387
 
2388
+ private handleKeyUp(event: KeyboardEvent): void {
2389
+ 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
+ if (this.isActionExternalDrag && this.actionDragIsCopy) {
2400
+ this.actionDragIsCopy = false;
2401
+ this.showDragHint();
2402
+ this.showActionOriginal(false);
2403
+ // If this is a last-action drag, hide the canvas preview again
2404
+ if (this.actionDragLastDetail?.isLastAction) {
2405
+ this.reprocessActionDrag();
2406
+ }
2407
+ this.requestUpdate();
2408
+ }
2409
+ }
2410
+ }
2411
+
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
2422
+ if (this.isActionExternalDrag && this.actionDragIsCopy) {
2423
+ this.actionDragIsCopy = false;
2424
+ this.showActionOriginal(false);
2425
+ if (this.actionDragLastDetail?.isLastAction) {
2426
+ this.reprocessActionDrag();
2427
+ }
2428
+ this.requestUpdate();
2429
+ }
2430
+ }
2431
+
2462
2432
  // --- Flow settings cookie (LRU, max 50 flows) ---
2463
2433
 
2464
2434
  static MAX_FLOW_SETTINGS = 50;
@@ -3597,6 +3567,9 @@ export class Editor extends RapidElement {
3597
3567
  }
3598
3568
 
3599
3569
  if (this.plumber.connectionDragging) {
3570
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
3571
+ this.startAutoScroll();
3572
+
3600
3573
  const targetNode = document.querySelector('temba-flow-node:hover');
3601
3574
 
3602
3575
  // Clear previous target styles
@@ -3683,9 +3656,15 @@ export class Editor extends RapidElement {
3683
3656
  this.isDragging = true;
3684
3657
  this.startAutoScroll();
3685
3658
 
3686
- if (this.shiftDragCopy) {
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) {
3687
3664
  this.performShiftDragCopy();
3688
3665
  this.shiftDragCopy = false;
3666
+ } else {
3667
+ this.showDragHint();
3689
3668
  }
3690
3669
  }
3691
3670
 
@@ -3696,14 +3675,14 @@ export class Editor extends RapidElement {
3696
3675
  }
3697
3676
 
3698
3677
  private performShiftDragCopy(): void {
3699
- if (!this.currentDragItem) return;
3678
+ if (!this.originalDragItem) return;
3700
3679
 
3701
- // Determine which items to copy (same logic as itemsToMove)
3680
+ // Always use the original items as the source for copying
3702
3681
  const itemsToCopy =
3703
- this.selectedItems.has(this.currentDragItem.uuid) &&
3704
- this.selectedItems.size > 1
3705
- ? Array.from(this.selectedItems)
3706
- : [this.currentDragItem.uuid];
3682
+ this.originalSelectedItems?.has(this.originalDragItem.uuid) &&
3683
+ (this.originalSelectedItems?.size ?? 0) > 1
3684
+ ? Array.from(this.originalSelectedItems!)
3685
+ : [this.originalDragItem.uuid];
3707
3686
 
3708
3687
  if (itemsToCopy.length === 0) return;
3709
3688
 
@@ -3719,19 +3698,44 @@ export class Editor extends RapidElement {
3719
3698
  }
3720
3699
  this.currentDragIsCopy = true;
3721
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
+
3722
3726
  // Update drag item to reference the copy
3723
- const newDragUuid = uuidMapping[this.currentDragItem.uuid];
3727
+ const newDragUuid = uuidMapping[this.originalDragItem.uuid];
3724
3728
  if (newDragUuid) {
3725
3729
  this.currentDragItem = {
3726
- ...this.currentDragItem,
3730
+ ...this.originalDragItem,
3727
3731
  uuid: newDragUuid
3728
3732
  };
3729
3733
  }
3730
3734
 
3731
3735
  // Update selected items to reference copies
3732
- if (this.selectedItems.size > 1) {
3736
+ if ((this.originalSelectedItems?.size ?? 0) > 1) {
3733
3737
  const newSelectedItems = new Set<string>();
3734
- for (const uuid of this.selectedItems) {
3738
+ for (const uuid of this.originalSelectedItems!) {
3735
3739
  const newUuid = uuidMapping[uuid];
3736
3740
  newSelectedItems.add(newUuid || uuid);
3737
3741
  }
@@ -3739,6 +3743,48 @@ export class Editor extends RapidElement {
3739
3743
  }
3740
3744
  }
3741
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
+
3742
3788
  private updateDragPositions(): void {
3743
3789
  if (!this.currentDragItem || !this.lastPointerPos) return;
3744
3790
 
@@ -3770,11 +3816,15 @@ export class Editor extends RapidElement {
3770
3816
  element.style.left = `${position.left + deltaX}px`;
3771
3817
  element.style.top = `${position.top + deltaY}px`;
3772
3818
  element.classList.add('dragging');
3819
+ if (this.currentDragIsCopy) {
3820
+ element.classList.add('drag-copy');
3821
+ }
3773
3822
  }
3774
3823
  }
3775
3824
  });
3776
3825
 
3777
3826
  this.plumber.revalidate(itemsToMove);
3827
+
3778
3828
  }
3779
3829
 
3780
3830
  private startAutoScroll(): void {
@@ -3784,7 +3834,10 @@ export class Editor extends RapidElement {
3784
3834
  if (!editor) return;
3785
3835
 
3786
3836
  const tick = () => {
3787
- if (!this.isDragging || !this.lastPointerPos) {
3837
+ if (
3838
+ (!this.isDragging && !this.plumber?.connectionDragging) ||
3839
+ !this.lastPointerPos
3840
+ ) {
3788
3841
  this.autoScrollAnimationId = null;
3789
3842
  return;
3790
3843
  }
@@ -3796,32 +3849,56 @@ export class Editor extends RapidElement {
3796
3849
  let scrollDx = 0;
3797
3850
  let scrollDy = 0;
3798
3851
 
3799
- // Left edge
3852
+ // Left edge (including beyond)
3800
3853
  const distFromLeft = mouseX - editorRect.left;
3801
- if (distFromLeft >= 0 && distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
3802
- const ratio = 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE;
3803
- scrollDx = -(ratio * AUTO_SCROLL_MAX_SPEED);
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);
3804
3863
  }
3805
3864
 
3806
- // Right edge
3865
+ // Right edge (including beyond)
3807
3866
  const distFromRight = editorRect.right - mouseX;
3808
- if (distFromRight >= 0 && distFromRight < AUTO_SCROLL_EDGE_ZONE) {
3809
- const ratio = 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE;
3810
- scrollDx = ratio * AUTO_SCROLL_MAX_SPEED;
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;
3811
3876
  }
3812
3877
 
3813
- // Top edge
3878
+ // Top edge (including beyond)
3814
3879
  const distFromTop = mouseY - editorRect.top;
3815
- if (distFromTop >= 0 && distFromTop < AUTO_SCROLL_EDGE_ZONE) {
3816
- const ratio = 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE;
3817
- scrollDy = -(ratio * AUTO_SCROLL_MAX_SPEED);
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);
3818
3889
  }
3819
3890
 
3820
- // Bottom edge
3891
+ // Bottom edge (including beyond)
3821
3892
  const distFromBottom = editorRect.bottom - mouseY;
3822
- if (distFromBottom >= 0 && distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
3823
- const ratio = 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE;
3824
- scrollDy = ratio * AUTO_SCROLL_MAX_SPEED;
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;
3825
3902
  }
3826
3903
 
3827
3904
  if (scrollDx !== 0 || scrollDy !== 0) {
@@ -3835,7 +3912,7 @@ export class Editor extends RapidElement {
3835
3912
  (editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
3836
3913
  const neededHeight =
3837
3914
  (editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
3838
- getStore().getState().expandCanvas(neededWidth, neededHeight);
3915
+ getStore()?.getState()?.expandCanvas(neededWidth, neededHeight);
3839
3916
  }
3840
3917
 
3841
3918
  editor.scrollLeft += scrollDx;
@@ -3923,10 +4000,10 @@ export class Editor extends RapidElement {
3923
4000
  const newPosition = { left: snappedLeft, top: snappedTop };
3924
4001
  newPositions[uuid] = newPosition;
3925
4002
 
3926
- // Remove dragging class
4003
+ // Remove dragging/copy classes
3927
4004
  const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3928
4005
  if (element) {
3929
- element.classList.remove('dragging');
4006
+ element.classList.remove('dragging', 'drag-copy');
3930
4007
  element.style.left = `${snappedLeft}px`;
3931
4008
  element.style.top = `${snappedTop}px`;
3932
4009
  }
@@ -3960,11 +4037,14 @@ export class Editor extends RapidElement {
3960
4037
  }
3961
4038
 
3962
4039
  // Reset all drag state
4040
+ this.hideDragHint();
3963
4041
  this.isDragging = false;
3964
4042
  this.isMouseDown = false;
3965
4043
  this.shiftDragCopy = false;
3966
4044
  this.currentDragIsCopy = false;
3967
4045
  this.currentDragItem = null;
4046
+ this.originalDragItem = null;
4047
+ this.originalSelectedItems = null;
3968
4048
  this.canvasMouseDown = false;
3969
4049
  this.autoScrollDeltaX = 0;
3970
4050
  this.autoScrollDeltaY = 0;
@@ -4950,6 +5030,32 @@ export class Editor extends RapidElement {
4950
5030
  isLastAction = false
4951
5031
  } = event.detail;
4952
5032
 
5033
+ // Track action external drag state for shift-copy support
5034
+ const isFirstExternalEvent = !this.isActionExternalDrag;
5035
+ this.isActionExternalDrag = true;
5036
+ this.actionDragLastDetail = {
5037
+ action,
5038
+ nodeUuid,
5039
+ actionIndex,
5040
+ mouseX,
5041
+ mouseY,
5042
+ actionHeight,
5043
+ isLastAction
5044
+ };
5045
+
5046
+ // Initialize copy mode from current shift state (handles shift held before drag)
5047
+ if (isFirstExternalEvent) {
5048
+ const shiftHeld =
5049
+ this.querySelector('#canvas')?.classList.contains('shift-held') ??
5050
+ false;
5051
+ if (shiftHeld) {
5052
+ this.actionDragIsCopy = true;
5053
+ this.showActionOriginal(true);
5054
+ } else {
5055
+ this.showDragHint();
5056
+ }
5057
+ }
5058
+
4953
5059
  // Check if mouse is over another execute_actions node
4954
5060
  const targetNode = this.getNodeAtPosition(mouseX, mouseY);
4955
5061
 
@@ -5046,9 +5152,9 @@ export class Editor extends RapidElement {
5046
5152
  `temba-flow-node[data-node-uuid="${nodeUuid}"]`
5047
5153
  );
5048
5154
 
5049
- // Show canvas drop preview only if this is NOT the last action
5050
- // Last actions can only be dropped on other nodes, not on canvas
5051
- if (!isLastAction) {
5155
+ // Show canvas drop preview if this is not the last action,
5156
+ // or if shift-copy is active (copying allows canvas drop for last action too)
5157
+ if (!isLastAction || this.actionDragIsCopy) {
5052
5158
  // Hide ghost when showing canvas preview (for canvas drops)
5053
5159
  if (sourceElement) {
5054
5160
  sourceElement.dispatchEvent(
@@ -5070,7 +5176,7 @@ export class Editor extends RapidElement {
5070
5176
  actionHeight
5071
5177
  };
5072
5178
  } else {
5073
- // For last action, keep ghost visible (can't drop on canvas)
5179
+ // For last action without copy, keep ghost visible (can't drop on canvas)
5074
5180
  if (sourceElement) {
5075
5181
  sourceElement.dispatchEvent(
5076
5182
  new CustomEvent('action-show-ghost', {
@@ -5107,6 +5213,75 @@ export class Editor extends RapidElement {
5107
5213
 
5108
5214
  this.canvasDropPreview = null;
5109
5215
  this.actionDragTargetNodeUuid = null;
5216
+ this.isActionExternalDrag = false;
5217
+ this.actionDragIsCopy = false;
5218
+ this.hideDragHint();
5219
+ this.actionDragLastDetail = null;
5220
+ }
5221
+
5222
+ /** Show or hide the original action element in the source node. */
5223
+ private showActionOriginal(visible: boolean): void {
5224
+ if (!this.actionDragLastDetail) return;
5225
+ const sourceElement = this.querySelector(
5226
+ `temba-flow-node[data-node-uuid="${this.actionDragLastDetail.nodeUuid}"]`
5227
+ );
5228
+ if (sourceElement) {
5229
+ sourceElement.dispatchEvent(
5230
+ new CustomEvent(
5231
+ visible ? 'action-show-original' : 'action-hide-original',
5232
+ { detail: {}, bubbles: false }
5233
+ )
5234
+ );
5235
+ }
5236
+ }
5237
+
5238
+ /**
5239
+ * Reprocess the last action drag event with the current shift-copy state.
5240
+ * Used when shift is toggled mid-drag to show/hide the canvas preview
5241
+ * for last-action drags.
5242
+ */
5243
+ private reprocessActionDrag(): void {
5244
+ if (!this.actionDragLastDetail) return;
5245
+ const { action, nodeUuid, actionIndex, mouseX, mouseY, actionHeight } =
5246
+ this.actionDragLastDetail;
5247
+
5248
+ const sourceElement = this.querySelector(
5249
+ `temba-flow-node[data-node-uuid="${nodeUuid}"]`
5250
+ );
5251
+
5252
+ // Only relevant when not hovering a target node
5253
+ if (this.actionDragTargetNodeUuid) return;
5254
+
5255
+ if (this.actionDragIsCopy) {
5256
+ // Shift pressed: show canvas preview
5257
+ if (sourceElement) {
5258
+ sourceElement.dispatchEvent(
5259
+ new CustomEvent('action-hide-ghost', {
5260
+ detail: {},
5261
+ bubbles: false
5262
+ })
5263
+ );
5264
+ }
5265
+ const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
5266
+ this.canvasDropPreview = {
5267
+ action,
5268
+ nodeUuid,
5269
+ actionIndex,
5270
+ position,
5271
+ actionHeight
5272
+ };
5273
+ } else {
5274
+ // Shift released: hide canvas preview, show ghost
5275
+ if (sourceElement) {
5276
+ sourceElement.dispatchEvent(
5277
+ new CustomEvent('action-show-ghost', {
5278
+ detail: {},
5279
+ bubbles: false
5280
+ })
5281
+ );
5282
+ }
5283
+ this.canvasDropPreview = null;
5284
+ }
5110
5285
  }
5111
5286
 
5112
5287
  private handleActionDropExternal(event: CustomEvent): void {
@@ -5119,6 +5294,15 @@ export class Editor extends RapidElement {
5119
5294
  isLastAction = false
5120
5295
  } = event.detail;
5121
5296
 
5297
+ const isCopy = this.actionDragIsCopy;
5298
+
5299
+ // Reset action drag state
5300
+ this.isActionExternalDrag = false;
5301
+ this.actionDragIsCopy = false;
5302
+ this.actionDragLastDetail = null;
5303
+ this.previousActionDragTargetNodeUuid = null;
5304
+ this.hideDragHint();
5305
+
5122
5306
  // Check if we're dropping on an existing execute_actions node
5123
5307
  const targetNodeUuid = this.actionDragTargetNodeUuid;
5124
5308
 
@@ -5135,7 +5319,8 @@ export class Editor extends RapidElement {
5135
5319
  sourceNodeUuid: nodeUuid,
5136
5320
  actionIndex,
5137
5321
  mouseX,
5138
- mouseY
5322
+ mouseY,
5323
+ isCopy
5139
5324
  },
5140
5325
  bubbles: false
5141
5326
  })
@@ -5148,9 +5333,9 @@ export class Editor extends RapidElement {
5148
5333
  return;
5149
5334
  }
5150
5335
 
5151
- // If this is the last action and we're not dropping on another node, do nothing
5336
+ // If this is the last action and not copying, do nothing
5152
5337
  // Last actions can only be moved to other nodes, not dropped on canvas
5153
- if (isLastAction) {
5338
+ if (isLastAction && !isCopy) {
5154
5339
  this.canvasDropPreview = null;
5155
5340
  this.actionDragTargetNodeUuid = null;
5156
5341
  return;
@@ -5160,28 +5345,36 @@ export class Editor extends RapidElement {
5160
5345
  // Snap to grid for the final drop position
5161
5346
  const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
5162
5347
 
5163
- // remove the action from the original node
5164
- const originalNode = this.definition.nodes.find((n) => n.uuid === nodeUuid);
5165
- if (!originalNode) return;
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;
5166
5354
 
5167
- const updatedActions = originalNode.actions.filter(
5168
- (_a, idx) => idx !== actionIndex
5169
- );
5355
+ const updatedActions = originalNode.actions.filter(
5356
+ (_a, idx) => idx !== actionIndex
5357
+ );
5170
5358
 
5171
- // if no actions remain, delete the node
5172
- if (updatedActions.length === 0) {
5173
- // Use deleteNodes to properly clean up Plumber connections before removing
5174
- this.deleteNodes([nodeUuid]);
5175
- } else {
5176
- // update the node
5177
- const updatedNode = { ...originalNode, actions: updatedActions };
5178
- getStore()?.getState().updateNode(nodeUuid, updatedNode);
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
+ }
5179
5368
  }
5180
5369
 
5181
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;
5182
5375
  const newNode: Node = {
5183
5376
  uuid: generateUUID(),
5184
- actions: [action],
5377
+ actions: [droppedAction],
5185
5378
  exits: [
5186
5379
  {
5187
5380
  uuid: generateUUID(),
@@ -5199,6 +5392,11 @@ export class Editor extends RapidElement {
5199
5392
  // add the new node
5200
5393
  getStore()?.getState().addNode(newNode, newNodeUI);
5201
5394
 
5395
+ // Copy localizations from the original action to the new one
5396
+ if (isCopy) {
5397
+ this.copyActionLocalizations(action.uuid, droppedAction.uuid);
5398
+ }
5399
+
5202
5400
  // clear the preview
5203
5401
  this.canvasDropPreview = null;
5204
5402
  this.actionDragTargetNodeUuid = null;
@@ -5209,6 +5407,27 @@ export class Editor extends RapidElement {
5209
5407
  });
5210
5408
  }
5211
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
+
5212
5431
  private getLocalizationLanguages(): Array<{ code: string; name: string }> {
5213
5432
  if (!this.definition) {
5214
5433
  return [];
@@ -6233,116 +6452,10 @@ export class Editor extends RapidElement {
6233
6452
  `;
6234
6453
  }
6235
6454
 
6236
- private handleLanguageIconClick(): void {
6237
- if (this.showLanguageOptions) {
6238
- this.showLanguageOptions = false;
6239
- return;
6240
- }
6241
- this.showLanguageOptions = true;
6242
- // Close on next click anywhere outside
6243
- requestAnimationFrame(() => {
6244
- const close = () => {
6245
- this.showLanguageOptions = false;
6246
- document.removeEventListener('click', close);
6247
- };
6248
- document.addEventListener('click', close, { once: true });
6249
- });
6250
- }
6251
-
6252
- private handleLanguageOptionSelected(event: CustomEvent): void {
6253
- if (!this.showLanguageOptions) return;
6254
- const selected = event.detail?.selected;
6255
- if (selected?.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
6256
- this.handleLanguageChange(this.definition?.language || '');
6257
- } else if (selected?.value) {
6258
- this.handleLanguageChange(selected.value);
6259
- }
6260
- this.showLanguageOptions = false;
6261
- }
6262
-
6263
- private renderToolbarTip(
6264
- text: string | TemplateResult,
6265
- content: TemplateResult
6266
- ): TemplateResult {
6267
- const tipContent = text;
6268
- return html`
6269
- <temba-tip
6270
- class="toolbar-tip"
6271
- .text=${typeof tipContent === 'string' ? tipContent : ''}
6272
- .content=${typeof tipContent === 'string' ? null : tipContent}
6273
- position="top"
6274
- >
6275
- ${content}
6276
- </temba-tip>
6277
- `;
6278
- }
6279
-
6280
- private isMacPlatform(): boolean {
6281
- return (
6282
- typeof navigator !== 'undefined' &&
6283
- /Mac|iPod|iPhone|iPad/.test(navigator.platform)
6284
- );
6285
- }
6286
-
6287
- private getSearchShortcutLabel(): string {
6288
- return this.isMacPlatform() ? '⌘F' : 'Ctrl+F';
6289
- }
6290
-
6291
- private renderToolbarShortcutLabel(
6292
- label: string,
6293
- shortcut: string
6294
- ): TemplateResult {
6295
- return html`<span style="display:inline-flex; align-items:center; gap:8px;">
6296
- <span>${label}</span>
6297
- <kbd>${shortcut}</kbd>
6298
- </span>`;
6299
- }
6300
-
6301
- private renderToolbarLanguageOption(
6302
- option: { name: string; value: string; percent?: number },
6303
- selected: boolean
6304
- ): TemplateResult {
6305
- if (option.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
6306
- const primaryBackground = selected ? '#e1e8ef' : '#edf1f5';
6307
- return html`
6308
- <div
6309
- style="display:flex; align-items:center; justify-content:space-between; gap:8px; background:${primaryBackground}; color:#2f3f52; border-radius:4px; padding:6px 10px;"
6310
- >
6311
- <span>${option.name}</span>
6312
- <span
6313
- style="display:inline-flex; align-items:center; border-radius:999px; background:rgba(47, 63, 82, 0.12); color:#2f3f52; font-size:10px; font-weight:700; line-height:1; padding:3px 7px;"
6314
- >Original</span
6315
- >
6316
- </div>
6317
- `;
6318
- }
6319
-
6320
- const isComplete = option.percent === 100;
6321
- const optionBg = isComplete ? '#d4f5e0' : '';
6322
- const optionHoverBg = isComplete ? '#c0edce' : '';
6323
- const optionRadius = isComplete ? 'border-radius:4px;' : '';
6324
- const percentColor = isComplete ? 'color:#1a7f37;' : 'color:#5f6b7a;';
6325
-
6326
- return html`
6327
- <div
6328
- style="display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 10px; ${optionBg ? `background:${optionBg};` : ''} ${optionRadius}"
6329
- @mouseenter=${isComplete ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.background = optionHoverBg; } : null}
6330
- @mouseleave=${isComplete ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.background = optionBg; } : null}
6331
- >
6332
- <span style="${isComplete ? 'color:#1a7f37;' : ''}">${option.name}</span>
6333
- <span style="font-size:11px; font-weight:600; ${percentColor}"
6334
- >${option.percent ?? 0}%</span
6335
- >
6336
- </div>
6337
- `;
6338
- }
6339
-
6340
- private renderToolbar(): TemplateResult {
6455
+ private renderToolbarElement(): TemplateResult {
6341
6456
  const languages = this.getLocalizationLanguages();
6342
6457
  const availableLanguages = this.getAvailableLanguages();
6343
6458
  const baseLanguage = this.definition?.language;
6344
- const languageOptionCount = (baseLanguage ? 1 : 0) + languages.length;
6345
- const showLanguageControls = languageOptionCount > 1;
6346
6459
  const baseLanguageName =
6347
6460
  availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
6348
6461
  baseLanguage ||
@@ -6364,11 +6477,6 @@ export class Editor extends RapidElement {
6364
6477
  const percent = Math.round(
6365
6478
  (progress.localized / Math.max(progress.total, 1)) * 100
6366
6479
  );
6367
- const hasTranslations = progress.total > 0;
6368
- const showLocalizationTools = Boolean(activeLanguage);
6369
- const searchTargetLabel = this.showMessageTable
6370
- ? 'Search table'
6371
- : 'Search flow';
6372
6480
  const languageOptions = [
6373
6481
  {
6374
6482
  name: baseLanguageName,
@@ -6390,179 +6498,56 @@ export class Editor extends RapidElement {
6390
6498
  ];
6391
6499
 
6392
6500
  return html`
6393
- <div class="editor-toolbar">
6394
- <div class="toolbar-left">
6395
- ${this.renderToolbarTip(
6396
- 'Flow View',
6397
- html`
6398
- <button
6399
- class="toolbar-btn ${!this.showMessageTable ? 'active' : ''}"
6400
- @click=${() => { this.showMessageTable = false; }}
6401
- aria-label="Flow View"
6402
- >
6403
- <temba-icon name="flow" size="1"></temba-icon>
6404
- </button>
6405
- `
6406
- )}
6407
- ${this.renderToolbarTip(
6408
- 'Table View',
6409
- html`
6410
- <button
6411
- class="toolbar-btn ${this.showMessageTable ? 'active' : ''}"
6412
- @click=${() => { this.showMessageTable = true; }}
6413
- aria-label="Table View"
6414
- >
6415
- <temba-icon name=${Icon.quick_replies} size="1"></temba-icon>
6416
- </button>
6417
- `
6418
- )}
6419
- ${showLanguageControls
6420
- ? html`
6421
- <div class="toolbar-divider"></div>
6422
- <div class="toolbar-language-group">
6423
- <div class="toolbar-language">
6424
- ${this.renderToolbarTip(
6425
- 'Change language',
6426
- html`
6427
- <button
6428
- class="language-pill ${isBaseSelected ? 'primary' : percent === 100 ? 'complete' : ''}"
6429
- id="language-btn"
6430
- @click=${this.handleLanguageIconClick}
6431
- aria-label="Change language"
6432
- >
6433
- <temba-icon name=${Icon.language}></temba-icon>
6434
- <span>${currentLanguage.name}</span>
6435
- ${!isBaseSelected
6436
- ? html`<span class="language-percent">${percent}%</span>`
6437
- : ''}
6438
- <temba-icon
6439
- class="language-pill-caret"
6440
- name=${this.showLanguageOptions
6441
- ? Icon.arrow_up
6442
- : Icon.arrow_down}
6443
- ></temba-icon>
6444
- </button>
6445
- `
6446
- )}
6447
- <temba-options
6448
- .anchorTo=${this.querySelector('#language-btn') as HTMLElement}
6449
- .options=${languageOptions}
6450
- .renderOption=${this.renderToolbarLanguageOption}
6451
- ?visible=${this.showLanguageOptions}
6452
- @temba-selection=${this.handleLanguageOptionSelected}
6453
- style="--temba-options-option-margin:4px; --temba-options-option-padding:0; --temba-options-option-radius:4px;"
6454
- min-width="230"
6455
- ></temba-options>
6456
- </div>
6457
- ${showLocalizationTools
6458
- ? this.renderToolbarTranslationTools(hasTranslations)
6459
- : ''}
6460
- </div>
6461
- `
6462
- : ''}
6463
- </div>
6464
- <div class="toolbar-right">
6465
- ${!this.showMessageTable ? html`
6466
- ${this.renderToolbarTip(
6467
- 'Zoom to fit',
6468
- html`
6469
- <button
6470
- class="toolbar-btn"
6471
- @click=${this.zoomToFit}
6472
- ?disabled=${!this.zoomInitialized || this.zoomFitted}
6473
- aria-label="Zoom to fit"
6474
- >
6475
- <temba-icon name=${Icon.zoom_fit} size="1"></temba-icon>
6476
- </button>
6477
- `
6478
- )}
6479
- <div class="toolbar-divider"></div>
6480
- ${this.renderToolbarTip(
6481
- 'Zoom out',
6482
- html`
6483
- <button
6484
- class="toolbar-btn"
6485
- @click=${this.zoomOut}
6486
- ?disabled=${!this.zoomInitialized || this.zoom <= 0.3}
6487
- aria-label="Zoom out"
6488
- >
6489
-
6490
- </button>
6491
- `
6492
- )}
6493
- <span class="toolbar-zoom-level">${this.zoomInitialized ? `${Math.round(this.zoom * 100)}%` : ''}</span>
6494
- ${this.renderToolbarTip(
6495
- 'Zoom in',
6496
- html`
6497
- <button
6498
- class="toolbar-btn"
6499
- @click=${this.zoomIn}
6500
- ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
6501
- aria-label="Zoom in"
6502
- >
6503
- +
6504
- </button>
6505
- `
6506
- )}
6507
- <div class="toolbar-divider"></div>
6508
- ${this.renderToolbarTip(
6509
- 'Zoom to 100%',
6510
- html`
6511
- <button
6512
- class="toolbar-btn"
6513
- @click=${this.zoomToFull}
6514
- ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
6515
- aria-label="Zoom to 100%"
6516
- >
6517
- <temba-icon name=${Icon.zoom_in} size="1"></temba-icon>
6518
- </button>
6519
- `
6520
- )}
6521
- <div class="toolbar-divider"></div>
6522
- ` : ''}
6523
- ${this.renderToolbarTip(
6524
- 'Revisions',
6525
- html`
6526
- <button
6527
- class="toolbar-btn ${!this.revisionsWindowHidden
6528
- ? 'active'
6529
- : ''}"
6530
- @click=${this.handleRevisionsTabClick}
6531
- aria-label="Revisions"
6532
- >
6533
- <temba-icon
6534
- name=${this.isSaving ? 'progress_spinner' : 'revisions'}
6535
- size="1"
6536
- ?spin=${this.isSaving}
6537
- ></temba-icon>
6538
- </button>
6539
- `
6540
- )}
6541
- <div class="toolbar-divider"></div>
6542
- ${this.renderToolbarTip(
6543
- this.renderToolbarShortcutLabel(
6544
- searchTargetLabel,
6545
- this.getSearchShortcutLabel()
6546
- ),
6547
- html`
6548
- <button
6549
- class="toolbar-btn"
6550
- @click=${this.openFlowSearch}
6551
- ?disabled=${!!this.viewingRevision}
6552
- aria-label=${searchTargetLabel}
6553
- >
6554
- <temba-icon name=${Icon.search} size="1"></temba-icon>
6555
- </button>
6556
- `
6557
- )}
6558
- </div>
6559
- </div>
6501
+ <temba-editor-toolbar
6502
+ ?message-view=${this.showMessageTable}
6503
+ .zoom=${this.zoom}
6504
+ ?zoom-initialized=${this.zoomInitialized}
6505
+ ?zoom-fitted=${this.zoomFitted}
6506
+ ?revisions-active=${!this.revisionsWindowHidden}
6507
+ ?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)}
6514
+ @temba-button-clicked=${this.handleToolbarAction}
6515
+ ></temba-editor-toolbar>
6560
6516
  `;
6561
6517
  }
6562
6518
 
6563
- private renderToolbarTranslationTools(_hasTranslations: boolean): TemplateResult {
6564
- // auto translate button hidden pending backend changes
6565
- return html``;
6519
+ private handleToolbarAction(e: CustomEvent): void {
6520
+ const detail = e.detail as ToolbarAction;
6521
+ switch (detail.action) {
6522
+ case 'view-change':
6523
+ this.showMessageTable = detail.view === 'table';
6524
+ break;
6525
+ case 'zoom-in':
6526
+ this.zoomIn();
6527
+ break;
6528
+ case 'zoom-out':
6529
+ this.zoomOut();
6530
+ break;
6531
+ case 'zoom-to-fit':
6532
+ this.zoomToFit();
6533
+ break;
6534
+ case 'zoom-to-full':
6535
+ this.zoomToFull();
6536
+ break;
6537
+ case 'revisions':
6538
+ this.handleRevisionsTabClick();
6539
+ break;
6540
+ case 'search':
6541
+ this.openFlowSearch();
6542
+ 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
+ }
6566
6551
  }
6567
6552
 
6568
6553
  /**
@@ -6721,7 +6706,7 @@ export class Editor extends RapidElement {
6721
6706
  return html`${style} ${this.renderIssuesWindow()}
6722
6707
  ${this.renderRevisionsWindow()} ${this.renderLocalizationWindow()}
6723
6708
  <div id="editor-container">
6724
- ${this.renderToolbar()}
6709
+ ${this.renderToolbarElement()}
6725
6710
  <div id="editor">
6726
6711
  ${this.showMessageTable
6727
6712
  ? html`<temba-message-table></temba-message-table>`
@@ -6852,6 +6837,7 @@ export class Editor extends RapidElement {
6852
6837
  `}
6853
6838
  </div>
6854
6839
  ${this.renderPendingCard()}
6840
+ <div class="drag-hint" id="drag-hint">Hold ⇧ to duplicate</div>
6855
6841
  </div>
6856
6842
  <div class="loupe" id="loupe">
6857
6843
  <div class="loupe-content" id="loupe-content"></div>