@nyaruka/temba-components 0.156.2 → 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,188 +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-caret {
709
- margin-left: 1px;
710
- --icon-color: currentColor;
711
- --icon-size: 12px;
712
- }
713
-
714
- .language-percent {
715
- display: inline-block;
716
- font-size: 12px;
717
- font-weight: 700;
718
- line-height: 1;
719
- color: #0064c8;
720
- white-space: nowrap;
721
- }
722
-
723
- .toolbar-zoom-level {
724
- font-size: 12px;
725
- min-width: 40px;
726
- text-align: center;
727
- color: #555;
728
- font-weight: 500;
729
- }
730
-
731
- .toolbar-translation {
732
- display: flex;
733
- align-items: center;
734
- gap: 4px;
735
- }
736
-
737
- .toolbar-btn.language-tool {
738
- width: var(--toolbar-translation-control-height);
739
- height: var(--toolbar-translation-control-height);
740
- }
741
-
742
567
  #editor {
743
568
  overflow: scroll;
744
569
  flex: 1;
@@ -806,6 +631,10 @@ export class Editor extends RapidElement {
806
631
  transition: none !important;
807
632
  }
808
633
 
634
+ #canvas.shift-held {
635
+ --shift-held-cursor: copy;
636
+ }
637
+
809
638
  #canvas.viewing-revision {
810
639
  pointer-events: none;
811
640
  }
@@ -1040,6 +869,10 @@ export class Editor extends RapidElement {
1040
869
  border-radius: var(--curvature);
1041
870
  }
1042
871
 
872
+ .draggable.selected.drag-copy {
873
+ outline: none;
874
+ }
875
+
1043
876
  /* Language banner replaced by toolbar language selector */
1044
877
 
1045
878
  .localization-window-content {
@@ -1431,6 +1264,36 @@ export class Editor extends RapidElement {
1431
1264
  opacity: 0.8;
1432
1265
  }
1433
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
+
1434
1297
  .reflow-card {
1435
1298
  position: absolute;
1436
1299
  top: 16px;
@@ -2054,6 +1917,8 @@ export class Editor extends RapidElement {
2054
1917
  document.removeEventListener('mouseup', this.boundMouseUp);
2055
1918
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
2056
1919
  document.removeEventListener('keydown', this.boundKeyDown);
1920
+ document.removeEventListener('keyup', this.boundKeyUp);
1921
+ window.removeEventListener('blur', this.boundWindowBlur);
2057
1922
  document.removeEventListener('touchmove', this.boundTouchMove);
2058
1923
  document.removeEventListener('touchend', this.boundTouchEnd);
2059
1924
  document.removeEventListener('touchcancel', this.boundTouchCancel);
@@ -2086,6 +1951,8 @@ export class Editor extends RapidElement {
2086
1951
  document.addEventListener('mouseup', this.boundMouseUp);
2087
1952
  document.addEventListener('mousedown', this.boundGlobalMouseDown);
2088
1953
  document.addEventListener('keydown', this.boundKeyDown);
1954
+ document.addEventListener('keyup', this.boundKeyUp);
1955
+ window.addEventListener('blur', this.boundWindowBlur);
2089
1956
  document.addEventListener('touchmove', this.boundTouchMove, {
2090
1957
  passive: false
2091
1958
  });
@@ -2419,7 +2286,44 @@ export class Editor extends RapidElement {
2419
2286
  }
2420
2287
  }
2421
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
+
2422
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
+
2423
2327
  // Cmd/Ctrl+F opens flow search (unless a dialog is already open)
2424
2328
  if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
2425
2329
  event.preventDefault();
@@ -2449,6 +2353,28 @@ export class Editor extends RapidElement {
2449
2353
  }
2450
2354
  }
2451
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
+
2452
2378
  // --- Flow settings cookie (LRU, max 50 flows) ---
2453
2379
 
2454
2380
  static MAX_FLOW_SETTINGS = 50;
@@ -3673,9 +3599,15 @@ export class Editor extends RapidElement {
3673
3599
  this.isDragging = true;
3674
3600
  this.startAutoScroll();
3675
3601
 
3676
- 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) {
3677
3607
  this.performShiftDragCopy();
3678
3608
  this.shiftDragCopy = false;
3609
+ } else {
3610
+ this.showDragHint();
3679
3611
  }
3680
3612
  }
3681
3613
 
@@ -3686,14 +3618,14 @@ export class Editor extends RapidElement {
3686
3618
  }
3687
3619
 
3688
3620
  private performShiftDragCopy(): void {
3689
- if (!this.currentDragItem) return;
3621
+ if (!this.originalDragItem) return;
3690
3622
 
3691
- // Determine which items to copy (same logic as itemsToMove)
3623
+ // Always use the original items as the source for copying
3692
3624
  const itemsToCopy =
3693
- this.selectedItems.has(this.currentDragItem.uuid) &&
3694
- this.selectedItems.size > 1
3695
- ? Array.from(this.selectedItems)
3696
- : [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];
3697
3629
 
3698
3630
  if (itemsToCopy.length === 0) return;
3699
3631
 
@@ -3709,19 +3641,44 @@ export class Editor extends RapidElement {
3709
3641
  }
3710
3642
  this.currentDragIsCopy = true;
3711
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
+
3712
3669
  // Update drag item to reference the copy
3713
- const newDragUuid = uuidMapping[this.currentDragItem.uuid];
3670
+ const newDragUuid = uuidMapping[this.originalDragItem.uuid];
3714
3671
  if (newDragUuid) {
3715
3672
  this.currentDragItem = {
3716
- ...this.currentDragItem,
3673
+ ...this.originalDragItem,
3717
3674
  uuid: newDragUuid
3718
3675
  };
3719
3676
  }
3720
3677
 
3721
3678
  // Update selected items to reference copies
3722
- if (this.selectedItems.size > 1) {
3679
+ if ((this.originalSelectedItems?.size ?? 0) > 1) {
3723
3680
  const newSelectedItems = new Set<string>();
3724
- for (const uuid of this.selectedItems) {
3681
+ for (const uuid of this.originalSelectedItems!) {
3725
3682
  const newUuid = uuidMapping[uuid];
3726
3683
  newSelectedItems.add(newUuid || uuid);
3727
3684
  }
@@ -3729,6 +3686,43 @@ export class Editor extends RapidElement {
3729
3686
  }
3730
3687
  }
3731
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
+
3732
3726
  private updateDragPositions(): void {
3733
3727
  if (!this.currentDragItem || !this.lastPointerPos) return;
3734
3728
 
@@ -3760,11 +3754,15 @@ export class Editor extends RapidElement {
3760
3754
  element.style.left = `${position.left + deltaX}px`;
3761
3755
  element.style.top = `${position.top + deltaY}px`;
3762
3756
  element.classList.add('dragging');
3757
+ if (this.currentDragIsCopy) {
3758
+ element.classList.add('drag-copy');
3759
+ }
3763
3760
  }
3764
3761
  }
3765
3762
  });
3766
3763
 
3767
3764
  this.plumber.revalidate(itemsToMove);
3765
+
3768
3766
  }
3769
3767
 
3770
3768
  private startAutoScroll(): void {
@@ -3913,10 +3911,10 @@ export class Editor extends RapidElement {
3913
3911
  const newPosition = { left: snappedLeft, top: snappedTop };
3914
3912
  newPositions[uuid] = newPosition;
3915
3913
 
3916
- // Remove dragging class
3914
+ // Remove dragging/copy classes
3917
3915
  const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
3918
3916
  if (element) {
3919
- element.classList.remove('dragging');
3917
+ element.classList.remove('dragging', 'drag-copy');
3920
3918
  element.style.left = `${snappedLeft}px`;
3921
3919
  element.style.top = `${snappedTop}px`;
3922
3920
  }
@@ -3950,11 +3948,14 @@ export class Editor extends RapidElement {
3950
3948
  }
3951
3949
 
3952
3950
  // Reset all drag state
3951
+ this.hideDragHint();
3953
3952
  this.isDragging = false;
3954
3953
  this.isMouseDown = false;
3955
3954
  this.shiftDragCopy = false;
3956
3955
  this.currentDragIsCopy = false;
3957
3956
  this.currentDragItem = null;
3957
+ this.originalDragItem = null;
3958
+ this.originalSelectedItems = null;
3958
3959
  this.canvasMouseDown = false;
3959
3960
  this.autoScrollDeltaX = 0;
3960
3961
  this.autoScrollDeltaY = 0;
@@ -6223,108 +6224,10 @@ export class Editor extends RapidElement {
6223
6224
  `;
6224
6225
  }
6225
6226
 
6226
- private handleLanguageIconClick(): void {
6227
- if (this.showLanguageOptions) {
6228
- this.showLanguageOptions = false;
6229
- return;
6230
- }
6231
- this.showLanguageOptions = true;
6232
- // Close on next click anywhere outside
6233
- requestAnimationFrame(() => {
6234
- const close = () => {
6235
- this.showLanguageOptions = false;
6236
- document.removeEventListener('click', close);
6237
- };
6238
- document.addEventListener('click', close, { once: true });
6239
- });
6240
- }
6241
-
6242
- private handleLanguageOptionSelected(event: CustomEvent): void {
6243
- if (!this.showLanguageOptions) return;
6244
- const selected = event.detail?.selected;
6245
- if (selected?.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
6246
- this.handleLanguageChange(this.definition?.language || '');
6247
- } else if (selected?.value) {
6248
- this.handleLanguageChange(selected.value);
6249
- }
6250
- this.showLanguageOptions = false;
6251
- }
6252
-
6253
- private renderToolbarTip(
6254
- text: string | TemplateResult,
6255
- content: TemplateResult
6256
- ): TemplateResult {
6257
- const tipContent = text;
6258
- return html`
6259
- <temba-tip
6260
- class="toolbar-tip"
6261
- .text=${typeof tipContent === 'string' ? tipContent : ''}
6262
- .content=${typeof tipContent === 'string' ? null : tipContent}
6263
- position="top"
6264
- >
6265
- ${content}
6266
- </temba-tip>
6267
- `;
6268
- }
6269
-
6270
- private isMacPlatform(): boolean {
6271
- return (
6272
- typeof navigator !== 'undefined' &&
6273
- /Mac|iPod|iPhone|iPad/.test(navigator.platform)
6274
- );
6275
- }
6276
-
6277
- private getSearchShortcutLabel(): string {
6278
- return this.isMacPlatform() ? '⌘F' : 'Ctrl+F';
6279
- }
6280
-
6281
- private renderToolbarShortcutLabel(
6282
- label: string,
6283
- shortcut: string
6284
- ): TemplateResult {
6285
- return html`<span style="display:inline-flex; align-items:center; gap:8px;">
6286
- <span>${label}</span>
6287
- <kbd>${shortcut}</kbd>
6288
- </span>`;
6289
- }
6290
-
6291
- private renderToolbarLanguageOption(
6292
- option: { name: string; value: string; percent?: number },
6293
- selected: boolean
6294
- ): TemplateResult {
6295
- if (option.value === PRIMARY_LANGUAGE_OPTION_VALUE) {
6296
- const primaryBackground = selected ? '#e1e8ef' : '#edf1f5';
6297
- return html`
6298
- <div
6299
- style="display:flex; align-items:center; justify-content:space-between; gap:8px; background:${primaryBackground}; color:#2f3f52; border-radius:4px; padding:6px 10px;"
6300
- >
6301
- <span>${option.name}</span>
6302
- <span
6303
- 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;"
6304
- >Original</span
6305
- >
6306
- </div>
6307
- `;
6308
- }
6309
-
6310
- return html`
6311
- <div
6312
- style="display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 10px;"
6313
- >
6314
- <span>${option.name}</span>
6315
- <span style="font-size:11px; font-weight:600; color:#5f6b7a;"
6316
- >${option.percent ?? 0}%</span
6317
- >
6318
- </div>
6319
- `;
6320
- }
6321
-
6322
- private renderToolbar(): TemplateResult {
6227
+ private renderToolbarElement(): TemplateResult {
6323
6228
  const languages = this.getLocalizationLanguages();
6324
6229
  const availableLanguages = this.getAvailableLanguages();
6325
6230
  const baseLanguage = this.definition?.language;
6326
- const languageOptionCount = (baseLanguage ? 1 : 0) + languages.length;
6327
- const showLanguageControls = languageOptionCount > 1;
6328
6231
  const baseLanguageName =
6329
6232
  availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
6330
6233
  baseLanguage ||
@@ -6346,11 +6249,6 @@ export class Editor extends RapidElement {
6346
6249
  const percent = Math.round(
6347
6250
  (progress.localized / Math.max(progress.total, 1)) * 100
6348
6251
  );
6349
- const hasTranslations = progress.total > 0;
6350
- const showLocalizationTools = Boolean(activeLanguage);
6351
- const searchTargetLabel = this.showMessageTable
6352
- ? 'Search table'
6353
- : 'Search flow';
6354
6252
  const languageOptions = [
6355
6253
  {
6356
6254
  name: baseLanguageName,
@@ -6372,205 +6270,56 @@ export class Editor extends RapidElement {
6372
6270
  ];
6373
6271
 
6374
6272
  return html`
6375
- <div class="editor-toolbar">
6376
- <div class="toolbar-left">
6377
- ${this.renderToolbarTip(
6378
- 'Flow View',
6379
- html`
6380
- <button
6381
- class="toolbar-btn ${!this.showMessageTable ? 'active' : ''}"
6382
- @click=${() => { this.showMessageTable = false; }}
6383
- aria-label="Flow View"
6384
- >
6385
- <temba-icon name="flow" size="1"></temba-icon>
6386
- </button>
6387
- `
6388
- )}
6389
- ${this.renderToolbarTip(
6390
- 'Table View',
6391
- html`
6392
- <button
6393
- class="toolbar-btn ${this.showMessageTable ? 'active' : ''}"
6394
- @click=${() => { this.showMessageTable = true; }}
6395
- aria-label="Table View"
6396
- >
6397
- <temba-icon name=${Icon.quick_replies} size="1"></temba-icon>
6398
- </button>
6399
- `
6400
- )}
6401
- ${showLanguageControls
6402
- ? html`
6403
- <div class="toolbar-divider"></div>
6404
- <div class="toolbar-language-group">
6405
- <div class="toolbar-language">
6406
- ${this.renderToolbarTip(
6407
- 'Change language',
6408
- html`
6409
- <button
6410
- class="language-pill ${isBaseSelected ? 'primary' : ''}"
6411
- id="language-btn"
6412
- @click=${this.handleLanguageIconClick}
6413
- aria-label="Change language"
6414
- >
6415
- <temba-icon name=${Icon.language}></temba-icon>
6416
- <span>${currentLanguage.name}</span>
6417
- ${!isBaseSelected
6418
- ? html`<span class="language-percent">${percent}%</span>`
6419
- : ''}
6420
- <temba-icon
6421
- class="language-pill-caret"
6422
- name=${this.showLanguageOptions
6423
- ? Icon.arrow_up
6424
- : Icon.arrow_down}
6425
- ></temba-icon>
6426
- </button>
6427
- `
6428
- )}
6429
- <temba-options
6430
- .anchorTo=${this.querySelector('#language-btn') as HTMLElement}
6431
- .options=${languageOptions}
6432
- .renderOption=${this.renderToolbarLanguageOption}
6433
- ?visible=${this.showLanguageOptions}
6434
- @temba-selection=${this.handleLanguageOptionSelected}
6435
- style="--temba-options-option-margin:4px; --temba-options-option-padding:0; --temba-options-option-radius:4px;"
6436
- min-width="230"
6437
- ></temba-options>
6438
- </div>
6439
- ${showLocalizationTools
6440
- ? this.renderToolbarTranslationTools(hasTranslations)
6441
- : ''}
6442
- </div>
6443
- `
6444
- : ''}
6445
- </div>
6446
- <div class="toolbar-right">
6447
- ${!this.showMessageTable ? html`
6448
- ${this.renderToolbarTip(
6449
- 'Zoom to fit',
6450
- html`
6451
- <button
6452
- class="toolbar-btn"
6453
- @click=${this.zoomToFit}
6454
- ?disabled=${!this.zoomInitialized || this.zoomFitted}
6455
- aria-label="Zoom to fit"
6456
- >
6457
- <temba-icon name=${Icon.zoom_fit} size="1"></temba-icon>
6458
- </button>
6459
- `
6460
- )}
6461
- <div class="toolbar-divider"></div>
6462
- ${this.renderToolbarTip(
6463
- 'Zoom out',
6464
- html`
6465
- <button
6466
- class="toolbar-btn"
6467
- @click=${this.zoomOut}
6468
- ?disabled=${!this.zoomInitialized || this.zoom <= 0.3}
6469
- aria-label="Zoom out"
6470
- >
6471
-
6472
- </button>
6473
- `
6474
- )}
6475
- <span class="toolbar-zoom-level">${this.zoomInitialized ? `${Math.round(this.zoom * 100)}%` : ''}</span>
6476
- ${this.renderToolbarTip(
6477
- 'Zoom in',
6478
- html`
6479
- <button
6480
- class="toolbar-btn"
6481
- @click=${this.zoomIn}
6482
- ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
6483
- aria-label="Zoom in"
6484
- >
6485
- +
6486
- </button>
6487
- `
6488
- )}
6489
- <div class="toolbar-divider"></div>
6490
- ${this.renderToolbarTip(
6491
- 'Zoom to 100%',
6492
- html`
6493
- <button
6494
- class="toolbar-btn"
6495
- @click=${this.zoomToFull}
6496
- ?disabled=${!this.zoomInitialized || this.zoom >= 1.0}
6497
- aria-label="Zoom to 100%"
6498
- >
6499
- <temba-icon name=${Icon.zoom_in} size="1"></temba-icon>
6500
- </button>
6501
- `
6502
- )}
6503
- <div class="toolbar-divider"></div>
6504
- ` : ''}
6505
- ${this.renderToolbarTip(
6506
- 'Revisions',
6507
- html`
6508
- <button
6509
- class="toolbar-btn ${!this.revisionsWindowHidden
6510
- ? 'active'
6511
- : ''}"
6512
- @click=${this.handleRevisionsTabClick}
6513
- aria-label="Revisions"
6514
- >
6515
- <temba-icon
6516
- name=${this.isSaving ? 'progress_spinner' : 'revisions'}
6517
- size="1"
6518
- ?spin=${this.isSaving}
6519
- ></temba-icon>
6520
- </button>
6521
- `
6522
- )}
6523
- <div class="toolbar-divider"></div>
6524
- ${this.renderToolbarTip(
6525
- this.renderToolbarShortcutLabel(
6526
- searchTargetLabel,
6527
- this.getSearchShortcutLabel()
6528
- ),
6529
- html`
6530
- <button
6531
- class="toolbar-btn"
6532
- @click=${this.openFlowSearch}
6533
- ?disabled=${!!this.viewingRevision}
6534
- aria-label=${searchTargetLabel}
6535
- >
6536
- <temba-icon name=${Icon.search} size="1"></temba-icon>
6537
- </button>
6538
- `
6539
- )}
6540
- </div>
6541
- </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>
6542
6288
  `;
6543
6289
  }
6544
6290
 
6545
- private renderToolbarTranslationTools(hasTranslations: boolean): TemplateResult {
6546
- const disableTranslationControls = Boolean(this.viewingRevision);
6547
- const autoTranslateLabel = this.autoTranslating
6548
- ? 'Stop auto translate'
6549
- : 'Auto translate';
6550
- return html`
6551
- <div class="toolbar-translation">
6552
- ${this.renderToolbarTip(
6553
- autoTranslateLabel,
6554
- html`
6555
- <button
6556
- class="toolbar-btn language-tool ${this.autoTranslating
6557
- ? 'active'
6558
- : ''}"
6559
- @click=${this.handleAutoTranslateClick}
6560
- ?disabled=${disableTranslationControls ||
6561
- (!this.autoTranslating && !hasTranslations)}
6562
- aria-label=${autoTranslateLabel}
6563
- >
6564
- <temba-icon
6565
- name=${this.autoTranslating ? 'progress_spinner' : Icon.ai}
6566
- size="0.9"
6567
- ?spin=${this.autoTranslating}
6568
- ></temba-icon>
6569
- </button>
6570
- `
6571
- )}
6572
- </div>
6573
- `;
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
+ }
6574
6323
  }
6575
6324
 
6576
6325
  /**
@@ -6729,7 +6478,7 @@ export class Editor extends RapidElement {
6729
6478
  return html`${style} ${this.renderIssuesWindow()}
6730
6479
  ${this.renderRevisionsWindow()} ${this.renderLocalizationWindow()}
6731
6480
  <div id="editor-container">
6732
- ${this.renderToolbar()}
6481
+ ${this.renderToolbarElement()}
6733
6482
  <div id="editor">
6734
6483
  ${this.showMessageTable
6735
6484
  ? html`<temba-message-table></temba-message-table>`
@@ -6860,6 +6609,7 @@ export class Editor extends RapidElement {
6860
6609
  `}
6861
6610
  </div>
6862
6611
  ${this.renderPendingCard()}
6612
+ <div class="drag-hint" id="drag-hint">Hold ⇧ to duplicate</div>
6863
6613
  </div>
6864
6614
  <div class="loupe" id="loupe">
6865
6615
  <div class="loupe-content" id="loupe-content"></div>