@nyaruka/temba-components 0.156.3 → 0.156.4

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,
@@ -134,7 +135,15 @@ interface LocalizationUpdate {
134
135
  }
135
136
 
136
137
  const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
137
- const PRIMARY_LANGUAGE_OPTION_VALUE = '__primary_language__';
138
+ export type ToolbarAction =
139
+ | { action: 'view-change'; view: 'flow' | 'table' }
140
+ | { action: 'zoom-in' }
141
+ | { action: 'zoom-out' }
142
+ | { action: 'zoom-to-fit' }
143
+ | { action: 'zoom-to-full' }
144
+ | { action: 'revisions' }
145
+ | { action: 'search' }
146
+ | { action: 'language-change'; isPrimary?: boolean; languageCode?: string };
138
147
  const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
139
148
 
140
149
  // How long the pending-changes auto-save countdown runs (in ms).
@@ -268,6 +277,13 @@ export class Editor extends RapidElement {
268
277
  private currentDragIsCopy = false;
269
278
  private dragStartPos = { x: 0, y: 0 };
270
279
 
280
+ // Mid-drag shift toggle: remember originals so we can switch between move/copy
281
+ private originalDragItem: DraggableItem | null = null;
282
+ private originalSelectedItems: Set<string> | null = null;
283
+
284
+ // Drag hint tooltip
285
+ private dragHintTimer: ReturnType<typeof setTimeout> | null = null;
286
+
271
287
  // Public getter for drag state
272
288
  public get dragging(): boolean {
273
289
  return this.isDragging;
@@ -458,9 +474,6 @@ export class Editor extends RapidElement {
458
474
  @property({ type: Boolean, reflect: true, attribute: 'message-view' })
459
475
  showMessageTable = false;
460
476
 
461
- @state()
462
- private showLanguageOptions = false;
463
-
464
477
  @state()
465
478
  private isCreatingNewNode = false;
466
479
 
@@ -506,16 +519,8 @@ export class Editor extends RapidElement {
506
519
  private getAvailableLanguages(): Array<{ code: string; name: string }> {
507
520
  // Use languages from workspace if available
508
521
  if (this.workspace?.languages && this.workspace.languages.length > 0) {
509
- const languageNames = new Intl.DisplayNames(['en'], { type: 'language' });
510
522
  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
- })
523
+ .map((code) => ({ code, name: getLanguageDisplayName(code) }))
519
524
  .filter((lang) => lang.code && lang.name);
520
525
  }
521
526
 
@@ -539,6 +544,8 @@ export class Editor extends RapidElement {
539
544
  private boundMouseUp = this.handleMouseUp.bind(this);
540
545
  private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
541
546
  private boundKeyDown = this.handleKeyDown.bind(this);
547
+ private boundKeyUp = this.handleKeyUp.bind(this);
548
+ private boundWindowBlur = this.handleWindowBlur.bind(this);
542
549
  private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
543
550
  private boundWheel = this.handleWheel.bind(this);
544
551
  private boundTouchMove = this.handleTouchMove.bind(this);
@@ -557,198 +564,6 @@ export class Editor extends RapidElement {
557
564
  min-height: 0;
558
565
  }
559
566
 
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
567
  #editor {
753
568
  overflow: scroll;
754
569
  flex: 1;
@@ -816,6 +631,10 @@ export class Editor extends RapidElement {
816
631
  transition: none !important;
817
632
  }
818
633
 
634
+ #canvas.shift-held {
635
+ --shift-held-cursor: copy;
636
+ }
637
+
819
638
  #canvas.viewing-revision {
820
639
  pointer-events: none;
821
640
  }
@@ -1050,6 +869,10 @@ export class Editor extends RapidElement {
1050
869
  border-radius: var(--curvature);
1051
870
  }
1052
871
 
872
+ .draggable.selected.drag-copy {
873
+ outline: none;
874
+ }
875
+
1053
876
  /* Language banner replaced by toolbar language selector */
1054
877
 
1055
878
  .localization-window-content {
@@ -1441,6 +1264,36 @@ export class Editor extends RapidElement {
1441
1264
  opacity: 0.8;
1442
1265
  }
1443
1266
 
1267
+ .drag-hint {
1268
+ position: absolute;
1269
+ bottom: 40px;
1270
+ left: 50%;
1271
+ transform: translateX(-50%);
1272
+ z-index: 100000;
1273
+ pointer-events: none;
1274
+ background: rgba(255, 255, 255, 0.5);
1275
+ backdrop-filter: blur(6px);
1276
+ color: #555;
1277
+ font-size: 18px;
1278
+ font-weight: 300;
1279
+ padding: 10px 32px;
1280
+ border-radius: 10px;
1281
+ border: 1px solid rgba(0, 0, 0, 0.08);
1282
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
1283
+ white-space: nowrap;
1284
+ display: none;
1285
+ }
1286
+
1287
+ .drag-hint.visible {
1288
+ display: block;
1289
+ animation: drag-hint-in 0.15s ease forwards;
1290
+ }
1291
+
1292
+ @keyframes drag-hint-in {
1293
+ from { opacity: 0; }
1294
+ to { opacity: 1; }
1295
+ }
1296
+
1444
1297
  .reflow-card {
1445
1298
  position: absolute;
1446
1299
  top: 16px;
@@ -2064,6 +1917,8 @@ export class Editor extends RapidElement {
2064
1917
  document.removeEventListener('mouseup', this.boundMouseUp);
2065
1918
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
2066
1919
  document.removeEventListener('keydown', this.boundKeyDown);
1920
+ document.removeEventListener('keyup', this.boundKeyUp);
1921
+ window.removeEventListener('blur', this.boundWindowBlur);
2067
1922
  document.removeEventListener('touchmove', this.boundTouchMove);
2068
1923
  document.removeEventListener('touchend', this.boundTouchEnd);
2069
1924
  document.removeEventListener('touchcancel', this.boundTouchCancel);
@@ -2096,6 +1951,8 @@ export class Editor extends RapidElement {
2096
1951
  document.addEventListener('mouseup', this.boundMouseUp);
2097
1952
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
2098
1953
  document.addEventListener('keydown', this.boundKeyDown);
1954
+ document.addEventListener('keyup', this.boundKeyUp);
1955
+ window.addEventListener('blur', this.boundWindowBlur);
2099
1956
  document.addEventListener('touchmove', this.boundTouchMove, {
2100
1957
  passive: false
2101
1958
  });
@@ -2429,7 +2286,44 @@ export class Editor extends RapidElement {
2429
2286
  }
2430
2287
  }
2431
2288
 
2289
+ private showDragHint(): void {
2290
+ if (this.isReadOnly()) return;
2291
+ const hint = this.querySelector('#drag-hint') as HTMLElement;
2292
+ if (!hint) return;
2293
+ this.dragHintTimer = setTimeout(() => {
2294
+ hint.classList.add('visible');
2295
+ this.dragHintTimer = null;
2296
+ }, 600);
2297
+ }
2298
+
2299
+ private hideDragHint(): void {
2300
+ if (this.dragHintTimer) {
2301
+ clearTimeout(this.dragHintTimer);
2302
+ this.dragHintTimer = null;
2303
+ }
2304
+ const hint = this.querySelector('#drag-hint') as HTMLElement;
2305
+ if (hint) {
2306
+ hint.classList.remove('visible');
2307
+ }
2308
+ }
2309
+
2432
2310
  private handleKeyDown(event: KeyboardEvent): void {
2311
+ if (event.key === 'Shift') {
2312
+ this.querySelector('#canvas')?.classList.add('shift-held');
2313
+
2314
+ // Toggle to copy mode mid-drag
2315
+ if (this.isDragging && !this.currentDragIsCopy) {
2316
+ this.hideDragHint();
2317
+ this.performShiftDragCopy();
2318
+ // Clone elements aren't in the DOM until Lit re-renders;
2319
+ // schedule position update after the next frame.
2320
+ requestAnimationFrame(() => {
2321
+ this.markCopyElements();
2322
+ this.updateDragPositions();
2323
+ });
2324
+ }
2325
+ }
2326
+
2433
2327
  // Cmd/Ctrl+F opens flow search (unless a dialog is already open)
2434
2328
  if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
2435
2329
  event.preventDefault();
@@ -2459,6 +2353,28 @@ export class Editor extends RapidElement {
2459
2353
  }
2460
2354
  }
2461
2355
 
2356
+ private handleKeyUp(event: KeyboardEvent): void {
2357
+ if (event.key === 'Shift') {
2358
+ this.querySelector('#canvas')?.classList.remove('shift-held');
2359
+
2360
+ // Toggle back to move mode mid-drag
2361
+ if (this.isDragging && this.currentDragIsCopy) {
2362
+ this.revertShiftDragCopy();
2363
+ requestAnimationFrame(() => this.updateDragPositions());
2364
+ }
2365
+ }
2366
+ }
2367
+
2368
+ private handleWindowBlur(): void {
2369
+ this.querySelector('#canvas')?.classList.remove('shift-held');
2370
+
2371
+ // Revert copy mode if blur happens mid-drag (keyup may never fire)
2372
+ if (this.isDragging && this.currentDragIsCopy) {
2373
+ this.revertShiftDragCopy();
2374
+ requestAnimationFrame(() => this.updateDragPositions());
2375
+ }
2376
+ }
2377
+
2462
2378
  // --- Flow settings cookie (LRU, max 50 flows) ---
2463
2379
 
2464
2380
  static MAX_FLOW_SETTINGS = 50;
@@ -3683,9 +3599,15 @@ export class Editor extends RapidElement {
3683
3599
  this.isDragging = true;
3684
3600
  this.startAutoScroll();
3685
3601
 
3686
- if (this.shiftDragCopy) {
3602
+ // Snapshot the original drag context before any copy occurs
3603
+ this.originalDragItem = { ...this.currentDragItem };
3604
+ this.originalSelectedItems = new Set(this.selectedItems);
3605
+
3606
+ if (this.shiftDragCopy || event.shiftKey) {
3687
3607
  this.performShiftDragCopy();
3688
3608
  this.shiftDragCopy = false;
3609
+ } else {
3610
+ this.showDragHint();
3689
3611
  }
3690
3612
  }
3691
3613
 
@@ -3696,14 +3618,14 @@ export class Editor extends RapidElement {
3696
3618
  }
3697
3619
 
3698
3620
  private performShiftDragCopy(): void {
3699
- if (!this.currentDragItem) return;
3621
+ if (!this.originalDragItem) return;
3700
3622
 
3701
- // Determine which items to copy (same logic as itemsToMove)
3623
+ // Always use the original items as the source for copying
3702
3624
  const itemsToCopy =
3703
- this.selectedItems.has(this.currentDragItem.uuid) &&
3704
- this.selectedItems.size > 1
3705
- ? Array.from(this.selectedItems)
3706
- : [this.currentDragItem.uuid];
3625
+ this.originalSelectedItems?.has(this.originalDragItem.uuid) &&
3626
+ (this.originalSelectedItems?.size ?? 0) > 1
3627
+ ? Array.from(this.originalSelectedItems!)
3628
+ : [this.originalDragItem.uuid];
3707
3629
 
3708
3630
  if (itemsToCopy.length === 0) return;
3709
3631
 
@@ -3719,19 +3641,44 @@ export class Editor extends RapidElement {
3719
3641
  }
3720
3642
  this.currentDragIsCopy = true;
3721
3643
 
3644
+ // Snap original items back to their start positions.
3645
+ // Set position while 'dragging' class is still applied so
3646
+ // transitions are disabled and the move is instant.
3647
+ for (const uuid of itemsToCopy) {
3648
+ const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3649
+ const type =
3650
+ element?.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
3651
+ const position = this.getPosition(uuid, type);
3652
+ if (element && position) {
3653
+ element.style.left = `${position.left}px`;
3654
+ element.style.top = `${position.top}px`;
3655
+ }
3656
+ }
3657
+ this.plumber.revalidate(itemsToCopy);
3658
+ // Force layout so the position is committed with transitions
3659
+ // disabled, then remove the dragging class.
3660
+ for (const uuid of itemsToCopy) {
3661
+ const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3662
+ if (element) {
3663
+ // Reading offsetHeight forces a synchronous layout
3664
+ void element.offsetHeight;
3665
+ element.classList.remove('dragging');
3666
+ }
3667
+ }
3668
+
3722
3669
  // Update drag item to reference the copy
3723
- const newDragUuid = uuidMapping[this.currentDragItem.uuid];
3670
+ const newDragUuid = uuidMapping[this.originalDragItem.uuid];
3724
3671
  if (newDragUuid) {
3725
3672
  this.currentDragItem = {
3726
- ...this.currentDragItem,
3673
+ ...this.originalDragItem,
3727
3674
  uuid: newDragUuid
3728
3675
  };
3729
3676
  }
3730
3677
 
3731
3678
  // Update selected items to reference copies
3732
- if (this.selectedItems.size > 1) {
3679
+ if ((this.originalSelectedItems?.size ?? 0) > 1) {
3733
3680
  const newSelectedItems = new Set<string>();
3734
- for (const uuid of this.selectedItems) {
3681
+ for (const uuid of this.originalSelectedItems!) {
3735
3682
  const newUuid = uuidMapping[uuid];
3736
3683
  newSelectedItems.add(newUuid || uuid);
3737
3684
  }
@@ -3739,6 +3686,43 @@ export class Editor extends RapidElement {
3739
3686
  }
3740
3687
  }
3741
3688
 
3689
+ private markCopyElements(): void {
3690
+ for (const uuid of this.copiedItemUuids) {
3691
+ const el = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3692
+ el?.classList.add('drag-copy');
3693
+ }
3694
+ }
3695
+
3696
+ private revertShiftDragCopy(): void {
3697
+ if (!this.originalDragItem) return;
3698
+
3699
+ // Remove the cloned items
3700
+ if (this.copiedItemUuids.length > 0) {
3701
+ const nodeUuids = this.copiedItemUuids.filter((uuid) =>
3702
+ this.definition.nodes.some((n) => n.uuid === uuid)
3703
+ );
3704
+ const stickyUuids = this.copiedItemUuids.filter(
3705
+ (uuid) => this.definition._ui?.stickies?.[uuid]
3706
+ );
3707
+
3708
+ if (nodeUuids.length > 0) {
3709
+ getStore().getState().removeNodes(nodeUuids);
3710
+ }
3711
+ if (stickyUuids.length > 0) {
3712
+ getStore().getState().removeStickyNotes(stickyUuids);
3713
+ }
3714
+ this.copiedItemUuids = [];
3715
+ }
3716
+
3717
+ this.currentDragIsCopy = false;
3718
+
3719
+ // Restore drag context to originals
3720
+ this.currentDragItem = { ...this.originalDragItem };
3721
+ if (this.originalSelectedItems) {
3722
+ this.selectedItems = new Set(this.originalSelectedItems);
3723
+ }
3724
+ }
3725
+
3742
3726
  private updateDragPositions(): void {
3743
3727
  if (!this.currentDragItem || !this.lastPointerPos) return;
3744
3728
 
@@ -3770,11 +3754,15 @@ export class Editor extends RapidElement {
3770
3754
  element.style.left = `${position.left + deltaX}px`;
3771
3755
  element.style.top = `${position.top + deltaY}px`;
3772
3756
  element.classList.add('dragging');
3757
+ if (this.currentDragIsCopy) {
3758
+ element.classList.add('drag-copy');
3759
+ }
3773
3760
  }
3774
3761
  }
3775
3762
  });
3776
3763
 
3777
3764
  this.plumber.revalidate(itemsToMove);
3765
+
3778
3766
  }
3779
3767
 
3780
3768
  private startAutoScroll(): void {
@@ -3923,10 +3911,10 @@ export class Editor extends RapidElement {
3923
3911
  const newPosition = { left: snappedLeft, top: snappedTop };
3924
3912
  newPositions[uuid] = newPosition;
3925
3913
 
3926
- // Remove dragging class
3914
+ // Remove dragging/copy classes
3927
3915
  const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3928
3916
  if (element) {
3929
- element.classList.remove('dragging');
3917
+ element.classList.remove('dragging', 'drag-copy');
3930
3918
  element.style.left = `${snappedLeft}px`;
3931
3919
  element.style.top = `${snappedTop}px`;
3932
3920
  }
@@ -3960,11 +3948,14 @@ export class Editor extends RapidElement {
3960
3948
  }
3961
3949
 
3962
3950
  // Reset all drag state
3951
+ this.hideDragHint();
3963
3952
  this.isDragging = false;
3964
3953
  this.isMouseDown = false;
3965
3954
  this.shiftDragCopy = false;
3966
3955
  this.currentDragIsCopy = false;
3967
3956
  this.currentDragItem = null;
3957
+ this.originalDragItem = null;
3958
+ this.originalSelectedItems = null;
3968
3959
  this.canvasMouseDown = false;
3969
3960
  this.autoScrollDeltaX = 0;
3970
3961
  this.autoScrollDeltaY = 0;
@@ -6233,116 +6224,10 @@ export class Editor extends RapidElement {
6233
6224
  `;
6234
6225
  }
6235
6226
 
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 {
6227
+ private renderToolbarElement(): TemplateResult {
6341
6228
  const languages = this.getLocalizationLanguages();
6342
6229
  const availableLanguages = this.getAvailableLanguages();
6343
6230
  const baseLanguage = this.definition?.language;
6344
- const languageOptionCount = (baseLanguage ? 1 : 0) + languages.length;
6345
- const showLanguageControls = languageOptionCount > 1;
6346
6231
  const baseLanguageName =
6347
6232
  availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
6348
6233
  baseLanguage ||
@@ -6364,11 +6249,6 @@ export class Editor extends RapidElement {
6364
6249
  const percent = Math.round(
6365
6250
  (progress.localized / Math.max(progress.total, 1)) * 100
6366
6251
  );
6367
- const hasTranslations = progress.total > 0;
6368
- const showLocalizationTools = Boolean(activeLanguage);
6369
- const searchTargetLabel = this.showMessageTable
6370
- ? 'Search table'
6371
- : 'Search flow';
6372
6252
  const languageOptions = [
6373
6253
  {
6374
6254
  name: baseLanguageName,
@@ -6390,179 +6270,56 @@ export class Editor extends RapidElement {
6390
6270
  ];
6391
6271
 
6392
6272
  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>
6273
+ <temba-editor-toolbar
6274
+ ?message-view=${this.showMessageTable}
6275
+ .zoom=${this.zoom}
6276
+ ?zoom-initialized=${this.zoomInitialized}
6277
+ ?zoom-fitted=${this.zoomFitted}
6278
+ ?revisions-active=${!this.revisionsWindowHidden}
6279
+ ?is-saving=${this.isSaving}
6280
+ ?search-disabled=${!!this.viewingRevision}
6281
+ .languageOptions=${languageOptions}
6282
+ current-language-name=${currentLanguage.name}
6283
+ ?is-base-language=${isBaseSelected}
6284
+ .languagePercent=${percent}
6285
+ ?show-localization-tools=${Boolean(activeLanguage)}
6286
+ @temba-button-clicked=${this.handleToolbarAction}
6287
+ ></temba-editor-toolbar>
6560
6288
  `;
6561
6289
  }
6562
6290
 
6563
- private renderToolbarTranslationTools(_hasTranslations: boolean): TemplateResult {
6564
- // auto translate button hidden pending backend changes
6565
- return html``;
6291
+ private handleToolbarAction(e: CustomEvent): void {
6292
+ const detail = e.detail as ToolbarAction;
6293
+ switch (detail.action) {
6294
+ case 'view-change':
6295
+ this.showMessageTable = detail.view === 'table';
6296
+ break;
6297
+ case 'zoom-in':
6298
+ this.zoomIn();
6299
+ break;
6300
+ case 'zoom-out':
6301
+ this.zoomOut();
6302
+ break;
6303
+ case 'zoom-to-fit':
6304
+ this.zoomToFit();
6305
+ break;
6306
+ case 'zoom-to-full':
6307
+ this.zoomToFull();
6308
+ break;
6309
+ case 'revisions':
6310
+ this.handleRevisionsTabClick();
6311
+ break;
6312
+ case 'search':
6313
+ this.openFlowSearch();
6314
+ break;
6315
+ case 'language-change':
6316
+ if (detail.isPrimary) {
6317
+ this.handleLanguageChange(this.definition?.language || '');
6318
+ } else if (detail.languageCode) {
6319
+ this.handleLanguageChange(detail.languageCode);
6320
+ }
6321
+ break;
6322
+ }
6566
6323
  }
6567
6324
 
6568
6325
  /**
@@ -6721,7 +6478,7 @@ export class Editor extends RapidElement {
6721
6478
  return html`${style} ${this.renderIssuesWindow()}
6722
6479
  ${this.renderRevisionsWindow()} ${this.renderLocalizationWindow()}
6723
6480
  <div id="editor-container">
6724
- ${this.renderToolbar()}
6481
+ ${this.renderToolbarElement()}
6725
6482
  <div id="editor">
6726
6483
  ${this.showMessageTable
6727
6484
  ? html`<temba-message-table></temba-message-table>`
@@ -6852,6 +6609,7 @@ export class Editor extends RapidElement {
6852
6609
  `}
6853
6610
  </div>
6854
6611
  ${this.renderPendingCard()}
6612
+ <div class="drag-hint" id="drag-hint">Hold ⇧ to duplicate</div>
6855
6613
  </div>
6856
6614
  <div class="loupe" id="loupe">
6857
6615
  <div class="loupe-content" id="loupe-content"></div>