@nyaruka/temba-components 0.141.1 → 0.142.1

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.
Files changed (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/static/svg/index.svg +1 -1
  3. package/dist/temba-components.js +849 -655
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/Icons.js +3 -1
  6. package/out-tsc/src/Icons.js.map +1 -1
  7. package/out-tsc/src/display/Button.js +2 -2
  8. package/out-tsc/src/display/Button.js.map +1 -1
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasMenu.js +24 -1
  11. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  12. package/out-tsc/src/flow/CanvasNode.js +7 -2
  13. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  14. package/out-tsc/src/flow/Editor.js +654 -66
  15. package/out-tsc/src/flow/Editor.js.map +1 -1
  16. package/out-tsc/src/flow/NodeEditor.js +8 -5
  17. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  18. package/out-tsc/src/flow/Plumber.js +40 -28
  19. package/out-tsc/src/flow/Plumber.js.map +1 -1
  20. package/out-tsc/src/flow/actions/send_msg.js +2 -1
  21. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  22. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -1
  23. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  24. package/out-tsc/src/flow/reflow.js +393 -0
  25. package/out-tsc/src/flow/reflow.js.map +1 -0
  26. package/out-tsc/src/flow/types.js.map +1 -1
  27. package/out-tsc/src/flow/utils.js +18 -3
  28. package/out-tsc/src/flow/utils.js.map +1 -1
  29. package/out-tsc/src/form/Compose.js +5 -0
  30. package/out-tsc/src/form/Compose.js.map +1 -1
  31. package/out-tsc/src/form/FieldRenderer.js +1 -3
  32. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  33. package/out-tsc/src/layout/Dialog.js +2 -0
  34. package/out-tsc/src/layout/Dialog.js.map +1 -1
  35. package/out-tsc/src/list/SortableList.js +39 -19
  36. package/out-tsc/src/list/SortableList.js.map +1 -1
  37. package/out-tsc/src/utils.js +5 -12
  38. package/out-tsc/src/utils.js.map +1 -1
  39. package/out-tsc/test/nodes/split_by_run_result.test.js +1 -2
  40. package/out-tsc/test/nodes/split_by_run_result.test.js.map +1 -1
  41. package/out-tsc/test/temba-canvas-menu.test.js +44 -0
  42. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  43. package/out-tsc/test/temba-flow-collision.test.js +25 -0
  44. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  45. package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
  46. package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
  47. package/out-tsc/test/temba-flow-editor.test.js +145 -1
  48. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  49. package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
  50. package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
  51. package/out-tsc/test/temba-flow-plumber.test.js +31 -0
  52. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  53. package/out-tsc/test/temba-flow-reflow.test.js +472 -0
  54. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
  55. package/out-tsc/test/temba-sortable-list.test.js +93 -0
  56. package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
  57. package/package.json +2 -2
  58. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  59. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  60. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  61. package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
  62. package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
  63. package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
  67. package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
  68. package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
  69. package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
  70. package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
  71. package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
  72. package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
  73. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  74. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  75. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  76. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  77. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  78. package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
  79. package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
  80. package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
  81. package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
  82. package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
  83. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  84. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  85. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  86. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  87. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  88. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  89. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  90. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  92. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  93. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  94. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  95. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  96. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  97. package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
  98. package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
  99. package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
  100. package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
  101. package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
  102. package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
  103. package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
  104. package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
  105. package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
  106. package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
  107. package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
  108. package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
  109. package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
  110. package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
  111. package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
  112. package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
  113. package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
  114. package/screenshots/truth/list/fields-dragging.png +0 -0
  115. package/screenshots/truth/list/sortable-dragging.png +0 -0
  116. package/screenshots/truth/modax/simple.png +0 -0
  117. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  118. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  124. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  125. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  126. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  127. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  128. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  129. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/src/Icons.ts +3 -1
  139. package/src/display/Button.ts +2 -2
  140. package/src/display/FloatingTab.ts +1 -1
  141. package/src/flow/CanvasMenu.ts +28 -3
  142. package/src/flow/CanvasNode.ts +7 -2
  143. package/src/flow/Editor.ts +755 -75
  144. package/src/flow/NodeEditor.ts +8 -4
  145. package/src/flow/Plumber.ts +65 -35
  146. package/src/flow/actions/send_msg.ts +2 -1
  147. package/src/flow/nodes/wait_for_response.ts +1 -1
  148. package/src/flow/reflow.ts +534 -0
  149. package/src/flow/types.ts +1 -0
  150. package/src/flow/utils.ts +19 -3
  151. package/src/form/Compose.ts +5 -0
  152. package/src/form/FieldRenderer.ts +1 -3
  153. package/src/layout/Dialog.ts +2 -0
  154. package/src/list/SortableList.ts +40 -19
  155. package/src/utils.ts +5 -12
  156. package/static/svg/index.svg +1 -1
  157. package/static/svg/work/traced/expand-06.svg +1 -0
  158. package/static/svg/work/used/expand-06.svg +3 -0
  159. package/test/nodes/split_by_run_result.test.ts +1 -2
  160. package/test/temba-canvas-menu.test.ts +55 -0
  161. package/test/temba-flow-collision.test.ts +31 -0
  162. package/test/temba-flow-editor-zoom.test.ts +583 -0
  163. package/test/temba-flow-editor.test.ts +187 -1
  164. package/test/temba-flow-node-drag.test.ts +171 -0
  165. package/test/temba-flow-plumber.test.ts +38 -0
  166. package/test/temba-flow-reflow.test.ts +703 -0
  167. package/test/temba-sortable-list.test.ts +120 -0
  168. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  169. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  170. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  171. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  172. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  173. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  174. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  175. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  176. package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
  177. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  178. package/screenshots/truth/compose/attachments-with-failures.png +0 -0
  179. package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
  180. package/screenshots/truth/contacts/tickets-assignment.png +0 -0
  181. package/screenshots/truth/contacts/tickets.png +0 -0
  182. package/screenshots/truth/flow/editor-basic.png +0 -0
  183. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  184. package/screenshots/truth/formfield/no-errors.png +0 -0
  185. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  186. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  187. package/screenshots/truth/omnibox/selected.png +0 -0
  188. package/screenshots/truth/select/enabled-multi-selection.png +0 -0
  189. package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
  190. package/screenshots/truth/select/endpoint-initial-value.png +0 -0
  191. package/screenshots/truth/select/initial-value.png +0 -0
  192. package/screenshots/truth/select/multi-reorder-final.png +0 -0
  193. package/screenshots/truth/select/multi-reorder-initial.png +0 -0
  194. package/screenshots/truth/select/selected-multi-test.png +0 -0
  195. package/screenshots/truth/select/value-initial.png +0 -0
  196. package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
  197. package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
  198. package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
  199. package/screenshots/truth/webchat/connecting-state.png +0 -0
@@ -9,11 +9,14 @@ import { repeat } from 'lit-html/directives/repeat.js';
9
9
  import { CustomEventType } from '../interfaces';
10
10
  import { generateUUID, postJSON, fetchResults, getClasses } from '../utils';
11
11
  import { TEMBA_COMPONENTS_VERSION } from '../version';
12
- import { formatIssueMessage, getNodeBounds, calculateReflowPositions, snapToGrid } from './utils';
12
+ import { formatIssueMessage, getNodeBounds, calculateReflowPositions, isRightClick, snapToGrid } from './utils';
13
13
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
14
+ import { calculateLayeredLayout, placeStickyNotes } from './reflow';
15
+ import { FloatingTab } from '../display/FloatingTab';
14
16
  import { ACTION_GROUP_METADATA } from './types';
15
17
  import { Plumber, calculateFlowchartPath, ARROW_LENGTH, ARROW_HALF_WIDTH, CURSOR_GAP } from './Plumber';
16
18
  import { CanvasNode } from './CanvasNode';
19
+ import { Icon } from '../Icons';
17
20
  export function findNodeForExit(definition, exitUuid) {
18
21
  for (const node of definition.nodes) {
19
22
  const exit = node.exits.find((e) => e.uuid === exitUuid);
@@ -25,7 +28,12 @@ export function findNodeForExit(definition, exitUuid) {
25
28
  }
26
29
  const SAVE_QUIET_TIME = 2000;
27
30
  const DRAG_THRESHOLD = 5;
31
+ const AUTO_SCROLL_EDGE_ZONE = 100;
32
+ const AUTO_SCROLL_MAX_SPEED = 15;
28
33
  const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
34
+ // How long the reflow auto-save countdown runs (in ms).
35
+ // Used in both the CSS animation and the JS setTimeout.
36
+ const REFLOW_AUTO_SAVE_DELAY = 5000;
29
37
  // Offset for positioning dropped action node relative to mouse cursor
30
38
  // Keep small to make drop location close to cursor position
31
39
  const DROP_PREVIEW_OFFSET_X = 20;
@@ -39,6 +47,12 @@ export class Editor extends RapidElement {
39
47
  get dragging() {
40
48
  return this.isDragging;
41
49
  }
50
+ clearReflowAutoSaveTimer() {
51
+ if (this.reflowAutoSaveTimer !== null) {
52
+ clearTimeout(this.reflowAutoSaveTimer);
53
+ this.reflowAutoSaveTimer = null;
54
+ }
55
+ }
42
56
  getAvailableLanguages() {
43
57
  var _b, _c, _d;
44
58
  // Use languages from workspace if available
@@ -99,6 +113,7 @@ export class Editor extends RapidElement {
99
113
  width: 100%;
100
114
  display: flex;
101
115
  padding-top: 20px;
116
+ transform-origin: 0 0;
102
117
  }
103
118
 
104
119
  #canvas {
@@ -608,7 +623,7 @@ export class Editor extends RapidElement {
608
623
  .save-indicator {
609
624
  position: absolute;
610
625
  top: 8px;
611
- right: 16px;
626
+ right: 240px;
612
627
  padding: 6px 10px;
613
628
  z-index: 10000;
614
629
  pointer-events: none;
@@ -619,6 +634,135 @@ export class Editor extends RapidElement {
619
634
  .save-indicator.visible {
620
635
  opacity: 1;
621
636
  }
637
+
638
+ .zoom-controls {
639
+ position: absolute;
640
+ top: 8px;
641
+ right: 16px;
642
+ z-index: 4999;
643
+ display: flex;
644
+ align-items: center;
645
+ gap: 2px;
646
+ background: white;
647
+ border-radius: var(--curvature);
648
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
649
+ padding: 4px;
650
+ user-select: none;
651
+ }
652
+
653
+ .zoom-controls button {
654
+ width: 28px;
655
+ height: 28px;
656
+ border: none;
657
+ background: transparent;
658
+ border-radius: var(--curvature);
659
+ cursor: pointer;
660
+ display: flex;
661
+ align-items: center;
662
+ justify-content: center;
663
+ padding: 0;
664
+ color: #555;
665
+ font-size: 16px;
666
+ line-height: 1;
667
+ outline: none;
668
+ }
669
+
670
+ .zoom-controls button:hover {
671
+ background: rgba(0, 0, 0, 0.06);
672
+ }
673
+
674
+ .zoom-controls button:disabled {
675
+ opacity: 0.3;
676
+ cursor: default;
677
+ background: transparent;
678
+ }
679
+
680
+ .zoom-controls .zoom-level {
681
+ font-size: 12px;
682
+ min-width: 40px;
683
+ text-align: center;
684
+ color: #555;
685
+ font-weight: 500;
686
+ }
687
+
688
+ .zoom-controls .zoom-divider {
689
+ width: 1px;
690
+ height: 16px;
691
+ background: #e0e0e0;
692
+ margin: 0 2px;
693
+ }
694
+
695
+ .reflow-card {
696
+ position: absolute;
697
+ top: 16px;
698
+ left: 50%;
699
+ transform: translateX(-50%);
700
+ z-index: 10000;
701
+ background: rgba(0, 0, 0, 0.65);
702
+ backdrop-filter: blur(8px);
703
+ border-radius: 10px;
704
+ padding: 12px 16px 8px;
705
+ display: flex;
706
+ flex-direction: column;
707
+ gap: 8px;
708
+ color: white;
709
+ font-size: 13px;
710
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
711
+ }
712
+
713
+ .reflow-card .reflow-top {
714
+ display: flex;
715
+ align-items: center;
716
+ gap: 10px;
717
+ }
718
+
719
+ .reflow-card .reflow-label {
720
+ white-space: nowrap;
721
+ }
722
+
723
+ .reflow-card button {
724
+ border: none;
725
+ border-radius: 6px;
726
+ padding: 6px 14px;
727
+ font-size: 13px;
728
+ font-weight: 500;
729
+ cursor: pointer;
730
+ white-space: nowrap;
731
+ transition: opacity 0.15s ease;
732
+ }
733
+
734
+ .reflow-card button:hover {
735
+ opacity: 0.85;
736
+ }
737
+
738
+ .reflow-card .reflow-discard {
739
+ background: rgba(255, 255, 255, 0.2);
740
+ color: white;
741
+ }
742
+
743
+ .reflow-meter {
744
+ height: 3px;
745
+ border-radius: 2px;
746
+ background: rgba(255, 255, 255, 0.15);
747
+ overflow: hidden;
748
+ }
749
+
750
+ .reflow-meter-fill {
751
+ height: 100%;
752
+ background: rgba(255, 255, 255, 0.5);
753
+ border-radius: 2px;
754
+ animation: reflow-countdown ${unsafeCSS(REFLOW_AUTO_SAVE_DELAY / 1000)}s
755
+ linear forwards;
756
+ }
757
+
758
+ @keyframes reflow-countdown {
759
+ from {
760
+ width: 100%;
761
+ }
762
+ to {
763
+ width: 0%;
764
+ }
765
+ }
622
766
  `;
623
767
  }
624
768
  constructor() {
@@ -635,6 +779,11 @@ export class Editor extends RapidElement {
635
779
  this.dragStartPos = { x: 0, y: 0 };
636
780
  this.currentDragItem = null;
637
781
  this.startPos = { left: 0, top: 0 };
782
+ // Auto-scroll state
783
+ this.autoScrollAnimationId = null;
784
+ this.autoScrollDeltaX = 0;
785
+ this.autoScrollDeltaY = 0;
786
+ this.lastMouseEvent = null;
638
787
  // Selection state
639
788
  this.selectedItems = new Set();
640
789
  this.isSelecting = false;
@@ -663,6 +812,12 @@ export class Editor extends RapidElement {
663
812
  this.isLoadingRevisions = false;
664
813
  this.isSaving = false;
665
814
  this.saveError = null;
815
+ this.zoom = 1.0;
816
+ this.zoomFitted = false;
817
+ this.reflowPending = false;
818
+ this.reflowUnsaved = false;
819
+ this.savedReflowPositions = null;
820
+ this.reflowAutoSaveTimer = null;
666
821
  this.preRevertState = null;
667
822
  this.translationCache = new Map();
668
823
  // NodeEditor state - handles both node and action editing
@@ -690,11 +845,13 @@ export class Editor extends RapidElement {
690
845
  this.boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
691
846
  this.boundKeyDown = this.handleKeyDown.bind(this);
692
847
  this.boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
848
+ this.boundWheel = this.handleWheel.bind(this);
693
849
  }
694
850
  firstUpdated(changes) {
695
851
  super.firstUpdated(changes);
696
852
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
697
853
  this.setupGlobalEventListeners();
854
+ this.updateZoomControlPositioning();
698
855
  if (changes.has('flow')) {
699
856
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
700
857
  this.fetchRevisions();
@@ -748,10 +905,10 @@ export class Editor extends RapidElement {
748
905
  const canvas = this.querySelector('#canvas');
749
906
  if (canvas) {
750
907
  const canvasRect = canvas.getBoundingClientRect();
751
- const menuX = canvasRect.left + snappedPosition.left - 40;
908
+ const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
752
909
  const menuY = isDragUp
753
- ? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
754
- : canvasRect.top + snappedPosition.top + 80; // just below placeholder
910
+ ? canvasRect.top + snappedPosition.top * this.zoom + 74 // just below placeholder bottom
911
+ : canvasRect.top + snappedPosition.top * this.zoom + 80; // just below placeholder
755
912
  const canvasMenu = this.querySelector('temba-canvas-menu');
756
913
  if (canvasMenu) {
757
914
  canvasMenu.show(menuX, menuY, {
@@ -825,8 +982,21 @@ export class Editor extends RapidElement {
825
982
  }
826
983
  if (changes.has('dirtyDate')) {
827
984
  if (this.dirtyDate) {
828
- this.isSaving = true;
829
- this.debouncedSave();
985
+ if (this.reflowPending) {
986
+ // This dirtyDate is from the reflow itself — suppress save
987
+ this.reflowPending = false;
988
+ }
989
+ else {
990
+ // Normal change — if reflow card was showing, it goes away
991
+ // because these changes will be included in the save
992
+ if (this.reflowUnsaved) {
993
+ this.reflowUnsaved = false;
994
+ this.savedReflowPositions = null;
995
+ this.clearReflowAutoSaveTimer();
996
+ }
997
+ this.isSaving = true;
998
+ this.debouncedSave();
999
+ }
830
1000
  }
831
1001
  }
832
1002
  if (changes.has('saveError') && this.saveError) {
@@ -861,6 +1031,11 @@ export class Editor extends RapidElement {
861
1031
  clearTimeout(this.saveTimer);
862
1032
  }
863
1033
  this.saveTimer = window.setTimeout(() => {
1034
+ // Don't auto-save while a reflow preview is pending user confirmation
1035
+ if (this.reflowUnsaved) {
1036
+ this.saveTimer = null;
1037
+ return;
1038
+ }
864
1039
  const now = new Date();
865
1040
  const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
866
1041
  if (timeSinceLastChange >= SAVE_QUIET_TIME) {
@@ -998,6 +1173,7 @@ export class Editor extends RapidElement {
998
1173
  }
999
1174
  disconnectedCallback() {
1000
1175
  super.disconnectedCallback();
1176
+ this.stopAutoScroll();
1001
1177
  if (this.saveTimer !== null) {
1002
1178
  clearTimeout(this.saveTimer);
1003
1179
  this.saveTimer = null;
@@ -1006,6 +1182,7 @@ export class Editor extends RapidElement {
1006
1182
  clearTimeout(this.activityTimer);
1007
1183
  this.activityTimer = null;
1008
1184
  }
1185
+ this.clearReflowAutoSaveTimer();
1009
1186
  document.removeEventListener('mousemove', this.boundMouseMove);
1010
1187
  document.removeEventListener('mouseup', this.boundMouseUp);
1011
1188
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
@@ -1014,6 +1191,10 @@ export class Editor extends RapidElement {
1014
1191
  if (canvas) {
1015
1192
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1016
1193
  }
1194
+ const editor = this.querySelector('#editor');
1195
+ if (editor) {
1196
+ editor.removeEventListener('wheel', this.boundWheel);
1197
+ }
1017
1198
  // Clear all flow-specific data from the store so stale data
1018
1199
  // isn't briefly visible when a different flow is opened.
1019
1200
  zustand.getState().clearFlowData();
@@ -1027,6 +1208,10 @@ export class Editor extends RapidElement {
1027
1208
  if (canvas) {
1028
1209
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1029
1210
  }
1211
+ const editor = this.querySelector('#editor');
1212
+ if (editor) {
1213
+ editor.addEventListener('wheel', this.boundWheel, { passive: false });
1214
+ }
1030
1215
  // Listen for action edit requests from flow nodes
1031
1216
  this.addEventListener(CustomEventType.ActionEditRequested, this.handleActionEditRequested.bind(this));
1032
1217
  // Listen for add action requests from flow nodes
@@ -1074,8 +1259,7 @@ export class Editor extends RapidElement {
1074
1259
  }
1075
1260
  }
1076
1261
  handleMouseDown(event) {
1077
- // ignore right clicks
1078
- if (event.button !== 0)
1262
+ if (isRightClick(event))
1079
1263
  return;
1080
1264
  if (this.isReadOnly())
1081
1265
  return;
@@ -1116,8 +1300,7 @@ export class Editor extends RapidElement {
1116
1300
  }
1117
1301
  handleGlobalMouseDown(event) {
1118
1302
  var _b;
1119
- // ignore right clicks
1120
- if (event.button !== 0)
1303
+ if (isRightClick(event))
1121
1304
  return;
1122
1305
  // Check if the click is within our canvas
1123
1306
  const canvasRect = (_b = this.querySelector('#grid')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
@@ -1165,8 +1348,8 @@ export class Editor extends RapidElement {
1165
1348
  if (canvasRect) {
1166
1349
  // Clear current selection
1167
1350
  this.selectedItems.clear();
1168
- const relativeX = event.clientX - canvasRect.left;
1169
- const relativeY = event.clientY - canvasRect.top;
1351
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1352
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1170
1353
  this.selectionBox = {
1171
1354
  startX: relativeX,
1172
1355
  startY: relativeY,
@@ -1188,6 +1371,135 @@ export class Editor extends RapidElement {
1188
1371
  this.requestUpdate();
1189
1372
  }
1190
1373
  }
1374
+ // --- Zoom ---
1375
+ setZoom(newZoom, center) {
1376
+ const clamped = Math.max(0.1, Math.min(1.0, Math.round(newZoom * 100) / 100));
1377
+ if (clamped === this.zoom)
1378
+ return;
1379
+ const editor = this.querySelector('#editor');
1380
+ const oldZoom = this.zoom;
1381
+ this.zoom = clamped;
1382
+ this.plumber.zoom = clamped;
1383
+ this.zoomFitted = false;
1384
+ if (editor && center) {
1385
+ const editorRect = editor.getBoundingClientRect();
1386
+ const ox = center.clientX - editorRect.left;
1387
+ const oy = center.clientY - editorRect.top;
1388
+ // Canvas point under cursor at old zoom
1389
+ const cx = (editor.scrollLeft + ox) / oldZoom;
1390
+ const cy = (editor.scrollTop + oy) / oldZoom;
1391
+ requestAnimationFrame(() => {
1392
+ editor.scrollLeft = cx * clamped - ox;
1393
+ editor.scrollTop = cy * clamped - oy;
1394
+ this.plumber.repaintEverything();
1395
+ });
1396
+ }
1397
+ else {
1398
+ requestAnimationFrame(() => this.plumber.repaintEverything());
1399
+ }
1400
+ }
1401
+ zoomIn() {
1402
+ this.setZoom(this.zoom + 0.05);
1403
+ }
1404
+ zoomOut() {
1405
+ this.setZoom(this.zoom - 0.05);
1406
+ }
1407
+ zoomToFit() {
1408
+ var _b;
1409
+ if (!this.definition || this.definition.nodes.length === 0)
1410
+ return;
1411
+ const editor = this.querySelector('#editor');
1412
+ if (!editor)
1413
+ return;
1414
+ // Calculate bounding box of all content in canvas coordinates
1415
+ let minX = Infinity;
1416
+ let minY = Infinity;
1417
+ let maxX = -Infinity;
1418
+ let maxY = -Infinity;
1419
+ this.definition.nodes.forEach((node) => {
1420
+ var _b;
1421
+ const ui = (_b = this.definition._ui) === null || _b === void 0 ? void 0 : _b.nodes[node.uuid];
1422
+ if (!(ui === null || ui === void 0 ? void 0 : ui.position))
1423
+ return;
1424
+ const el = this.querySelector(`[id="${node.uuid}"]`);
1425
+ if (!el)
1426
+ return;
1427
+ const w = el.offsetWidth;
1428
+ const h = el.offsetHeight;
1429
+ minX = Math.min(minX, ui.position.left);
1430
+ minY = Math.min(minY, ui.position.top);
1431
+ maxX = Math.max(maxX, ui.position.left + w);
1432
+ maxY = Math.max(maxY, ui.position.top + h);
1433
+ });
1434
+ const stickies = ((_b = this.definition._ui) === null || _b === void 0 ? void 0 : _b.stickies) || {};
1435
+ Object.entries(stickies).forEach(([uuid, sticky]) => {
1436
+ if (!sticky.position)
1437
+ return;
1438
+ const el = this.querySelector(`temba-sticky-note[uuid="${uuid}"]`);
1439
+ if (!el)
1440
+ return;
1441
+ const w = el.offsetWidth;
1442
+ const h = el.offsetHeight;
1443
+ minX = Math.min(minX, sticky.position.left);
1444
+ minY = Math.min(minY, sticky.position.top);
1445
+ maxX = Math.max(maxX, sticky.position.left + w);
1446
+ maxY = Math.max(maxY, sticky.position.top + h);
1447
+ });
1448
+ if (minX === Infinity)
1449
+ return;
1450
+ const contentWidth = maxX - minX;
1451
+ const contentHeight = maxY - minY;
1452
+ const padding = 40;
1453
+ const availWidth = editor.clientWidth - padding * 2;
1454
+ const availHeight = editor.clientHeight - padding * 2;
1455
+ const scaleX = availWidth / contentWidth;
1456
+ const scaleY = availHeight / contentHeight;
1457
+ let fitZoom = Math.min(scaleX, scaleY, 1.0);
1458
+ fitZoom = Math.max(fitZoom, 0.1);
1459
+ fitZoom = Math.round(fitZoom * 20) / 20; // round to nearest 0.05
1460
+ this.zoom = fitZoom;
1461
+ this.plumber.zoom = fitZoom;
1462
+ this.zoomFitted = true;
1463
+ // Center of content in canvas coordinates, plus grid/canvas margin offset
1464
+ const centerX = (minX + maxX) / 2 + 40;
1465
+ const centerY = (minY + maxY) / 2 + 40;
1466
+ requestAnimationFrame(() => {
1467
+ editor.scrollLeft = centerX * fitZoom - editor.clientWidth / 2;
1468
+ editor.scrollTop = centerY * fitZoom - editor.clientHeight / 2;
1469
+ this.plumber.repaintEverything();
1470
+ });
1471
+ }
1472
+ zoomToFull() {
1473
+ this.setZoom(1.0);
1474
+ }
1475
+ /** Adjust zoom control right offset and floating tab positions */
1476
+ updateZoomControlPositioning() {
1477
+ requestAnimationFrame(() => {
1478
+ const editor = this.querySelector('#editor');
1479
+ const zoomControls = this.querySelector('.zoom-controls');
1480
+ if (editor && zoomControls) {
1481
+ // Match right spacing to the top spacing (8px) by accounting for
1482
+ // the scrollbar width
1483
+ const scrollbarWidth = editor.offsetWidth - editor.clientWidth;
1484
+ zoomControls.style.right = `${8 + scrollbarWidth}px`;
1485
+ }
1486
+ if (zoomControls) {
1487
+ const rect = zoomControls.getBoundingClientRect();
1488
+ FloatingTab.START_TOP = rect.bottom + 8;
1489
+ FloatingTab.updateAllPositions();
1490
+ }
1491
+ });
1492
+ }
1493
+ handleWheel(event) {
1494
+ if (!event.ctrlKey && !event.metaKey)
1495
+ return;
1496
+ event.preventDefault();
1497
+ const delta = event.deltaY > 0 ? -0.05 : 0.05;
1498
+ this.setZoom(this.zoom + delta, {
1499
+ clientX: event.clientX,
1500
+ clientY: event.clientY
1501
+ });
1502
+ }
1191
1503
  showDeleteConfirmation() {
1192
1504
  const itemCount = this.selectedItems.size;
1193
1505
  const itemType = itemCount === 1 ? 'item' : 'items';
@@ -1212,6 +1524,127 @@ export class Editor extends RapidElement {
1212
1524
  document.body.removeChild(dialog);
1213
1525
  });
1214
1526
  }
1527
+ performReflow() {
1528
+ var _b, _c, _d;
1529
+ if (!this.definition || this.definition.nodes.length === 0)
1530
+ return;
1531
+ // Save current positions for discard (nodes + stickies)
1532
+ const savedPositions = {};
1533
+ for (const node of this.definition.nodes) {
1534
+ const ui = (_b = this.definition._ui) === null || _b === void 0 ? void 0 : _b.nodes[node.uuid];
1535
+ if (ui === null || ui === void 0 ? void 0 : ui.position) {
1536
+ savedPositions[node.uuid] = { ...ui.position };
1537
+ }
1538
+ }
1539
+ const stickies = ((_c = this.definition._ui) === null || _c === void 0 ? void 0 : _c.stickies) || {};
1540
+ for (const [uuid, sticky] of Object.entries(stickies)) {
1541
+ if (sticky.position) {
1542
+ savedPositions[uuid] = { ...sticky.position };
1543
+ }
1544
+ }
1545
+ this.savedReflowPositions = savedPositions;
1546
+ // Save old node positions before reflow for sticky proximity calculation
1547
+ const oldNodePositions = {};
1548
+ for (const node of this.definition.nodes) {
1549
+ const ui = (_d = this.definition._ui) === null || _d === void 0 ? void 0 : _d.nodes[node.uuid];
1550
+ if (ui === null || ui === void 0 ? void 0 : ui.position) {
1551
+ oldNodePositions[node.uuid] = { ...ui.position };
1552
+ }
1553
+ }
1554
+ // Identify start node (first in sorted array)
1555
+ const startNodeUuid = this.definition.nodes[0].uuid;
1556
+ // Gather node sizes from DOM
1557
+ const nodeSizes = new Map();
1558
+ const getNodeSize = (uuid) => {
1559
+ const element = this.querySelector(`[id="${uuid}"]`);
1560
+ if (element) {
1561
+ const size = {
1562
+ width: element.offsetWidth,
1563
+ height: element.offsetHeight
1564
+ };
1565
+ nodeSizes.set(uuid, size);
1566
+ return size;
1567
+ }
1568
+ const fallback = { width: 200, height: 100 };
1569
+ nodeSizes.set(uuid, fallback);
1570
+ return fallback;
1571
+ };
1572
+ // Compute new layout
1573
+ const newPositions = calculateLayeredLayout(this.definition.nodes, this.definition._ui.nodes, startNodeUuid, getNodeSize);
1574
+ // Place sticky notes next to their closest nodes
1575
+ if (Object.keys(stickies).length > 0) {
1576
+ const stickySizes = new Map();
1577
+ for (const uuid of Object.keys(stickies)) {
1578
+ const el = this.querySelector(`temba-sticky-note[uuid="${uuid}"]`);
1579
+ if (el) {
1580
+ stickySizes.set(uuid, {
1581
+ width: el.offsetWidth,
1582
+ height: el.offsetHeight
1583
+ });
1584
+ }
1585
+ else {
1586
+ stickySizes.set(uuid, { width: 182, height: 100 });
1587
+ }
1588
+ }
1589
+ const stickyPositions = placeStickyNotes(stickies, oldNodePositions, newPositions, nodeSizes, stickySizes, startNodeUuid);
1590
+ // Merge sticky positions into newPositions
1591
+ Object.assign(newPositions, stickyPositions);
1592
+ }
1593
+ // Cancel any in-flight save timer so it doesn't persist the reflowed
1594
+ // layout before the user has a chance to Save or Discard.
1595
+ if (this.saveTimer !== null) {
1596
+ clearTimeout(this.saveTimer);
1597
+ this.saveTimer = null;
1598
+ }
1599
+ // Suppress the auto-save from this updateCanvasPositions call
1600
+ this.reflowPending = true;
1601
+ this.reflowUnsaved = true;
1602
+ // Apply new positions
1603
+ getStore().getState().updateCanvasPositions(newPositions);
1604
+ // Update canvas size and repaint connections
1605
+ this.updateCanvasSize();
1606
+ requestAnimationFrame(() => {
1607
+ this.plumber.repaintEverything();
1608
+ });
1609
+ // Scroll to top-left so the start node is visible
1610
+ const editor = this.querySelector('#editor');
1611
+ if (editor) {
1612
+ editor.scrollTo({ left: 0, top: 0, behavior: 'smooth' });
1613
+ }
1614
+ // Start auto-save countdown (duration shared with CSS animation)
1615
+ this.clearReflowAutoSaveTimer();
1616
+ this.reflowAutoSaveTimer = setTimeout(() => {
1617
+ this.reflowAutoSaveTimer = null;
1618
+ if (this.reflowUnsaved) {
1619
+ this.reflowUnsaved = false;
1620
+ this.savedReflowPositions = null;
1621
+ this.saveChanges();
1622
+ }
1623
+ }, REFLOW_AUTO_SAVE_DELAY);
1624
+ }
1625
+ handleReflowDiscard() {
1626
+ this.reflowUnsaved = false;
1627
+ this.clearReflowAutoSaveTimer();
1628
+ if (this.savedReflowPositions) {
1629
+ // Cancel any pending save timer before reverting
1630
+ if (this.saveTimer !== null) {
1631
+ clearTimeout(this.saveTimer);
1632
+ this.saveTimer = null;
1633
+ }
1634
+ // Suppress the auto-save from reverting positions
1635
+ this.reflowPending = true;
1636
+ getStore().getState().updateCanvasPositions(this.savedReflowPositions);
1637
+ this.savedReflowPositions = null;
1638
+ // Clear dirty state since we reverted to the saved version
1639
+ setTimeout(() => {
1640
+ getStore().getState().setDirtyDate(null);
1641
+ this.isSaving = false;
1642
+ }, 0);
1643
+ requestAnimationFrame(() => {
1644
+ this.plumber.repaintEverything();
1645
+ });
1646
+ }
1647
+ }
1215
1648
  deleteNodes(uuids) {
1216
1649
  // Remove nodes from the definition - CanvasNode will handle plumber cleanup
1217
1650
  if (uuids.length > 0) {
@@ -1233,8 +1666,8 @@ export class Editor extends RapidElement {
1233
1666
  const canvasRect = (_b = this.querySelector('#canvas')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
1234
1667
  if (!canvasRect)
1235
1668
  return;
1236
- const relativeX = event.clientX - canvasRect.left;
1237
- const relativeY = event.clientY - canvasRect.top;
1669
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1670
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1238
1671
  this.selectionBox = {
1239
1672
  ...this.selectionBox,
1240
1673
  endX: relativeX,
@@ -1259,13 +1692,12 @@ export class Editor extends RapidElement {
1259
1692
  if (nodeElement) {
1260
1693
  const position = (_c = (_b = this.definition._ui) === null || _b === void 0 ? void 0 : _b.nodes[node.uuid]) === null || _c === void 0 ? void 0 : _c.position;
1261
1694
  if (position) {
1262
- const rect = nodeElement.getBoundingClientRect();
1263
1695
  const canvasRect = (_d = this.querySelector('#canvas')) === null || _d === void 0 ? void 0 : _d.getBoundingClientRect();
1264
1696
  if (canvasRect) {
1265
1697
  const nodeLeft = position.left;
1266
1698
  const nodeTop = position.top;
1267
- const nodeRight = nodeLeft + rect.width;
1268
- const nodeBottom = nodeTop + rect.height;
1699
+ const nodeRight = nodeLeft + nodeElement.offsetWidth;
1700
+ const nodeBottom = nodeTop + nodeElement.offsetHeight;
1269
1701
  // Check if selection box intersects with node
1270
1702
  if (boxLeft < nodeRight &&
1271
1703
  boxRight > nodeLeft &&
@@ -1490,8 +1922,8 @@ export class Editor extends RapidElement {
1490
1922
  const canvas = this.querySelector('#canvas');
1491
1923
  if (canvas) {
1492
1924
  const canvasRect = canvas.getBoundingClientRect();
1493
- const relativeX = event.clientX - canvasRect.left;
1494
- const relativeY = event.clientY - canvasRect.top;
1925
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1926
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1495
1927
  const placeholderWidth = 200;
1496
1928
  const placeholderHeight = 64;
1497
1929
  const arrowLength = ARROW_LENGTH;
@@ -1527,37 +1959,119 @@ export class Editor extends RapidElement {
1527
1959
  // Handle item dragging
1528
1960
  if (!this.isMouseDown || !this.currentDragItem)
1529
1961
  return;
1530
- const deltaX = event.clientX - this.dragStartPos.x;
1531
- const deltaY = event.clientY - this.dragStartPos.y;
1962
+ this.lastMouseEvent = event;
1963
+ const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
1964
+ const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
1532
1965
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1533
1966
  // Only start dragging if we've moved beyond the threshold
1534
1967
  if (!this.isDragging && distance > DRAG_THRESHOLD) {
1535
1968
  this.isDragging = true;
1969
+ this.startAutoScroll();
1536
1970
  }
1537
1971
  // If we're actually dragging, update positions
1538
1972
  if (this.isDragging) {
1539
- // Determine what items to move
1540
- const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
1541
- this.selectedItems.size > 1
1542
- ? Array.from(this.selectedItems)
1543
- : [this.currentDragItem.uuid];
1544
- itemsToMove.forEach((uuid) => {
1545
- const element = this.querySelector(`[uuid="${uuid}"]`);
1546
- if (element) {
1547
- const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1548
- const position = this.getPosition(uuid, type);
1549
- if (position) {
1550
- const newLeft = position.left + deltaX;
1551
- const newTop = position.top + deltaY;
1552
- // Update the visual position during drag
1553
- element.style.left = `${newLeft}px`;
1554
- element.style.top = `${newTop}px`;
1555
- // Add dragging class to ensure highest z-index
1556
- element.classList.add('dragging');
1557
- }
1973
+ this.updateDragPositions();
1974
+ }
1975
+ }
1976
+ updateDragPositions() {
1977
+ if (!this.currentDragItem || !this.lastMouseEvent)
1978
+ return;
1979
+ // Convert screen + scroll delta to canvas delta
1980
+ const deltaX = (this.lastMouseEvent.clientX -
1981
+ this.dragStartPos.x +
1982
+ this.autoScrollDeltaX) /
1983
+ this.zoom;
1984
+ const deltaY = (this.lastMouseEvent.clientY -
1985
+ this.dragStartPos.y +
1986
+ this.autoScrollDeltaY) /
1987
+ this.zoom;
1988
+ const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
1989
+ this.selectedItems.size > 1
1990
+ ? Array.from(this.selectedItems)
1991
+ : [this.currentDragItem.uuid];
1992
+ itemsToMove.forEach((uuid) => {
1993
+ const element = this.querySelector(`[uuid="${uuid}"]`);
1994
+ if (element) {
1995
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1996
+ const position = this.getPosition(uuid, type);
1997
+ if (position) {
1998
+ element.style.left = `${position.left + deltaX}px`;
1999
+ element.style.top = `${position.top + deltaY}px`;
2000
+ element.classList.add('dragging');
1558
2001
  }
1559
- });
1560
- this.plumber.revalidate(itemsToMove);
2002
+ }
2003
+ });
2004
+ this.plumber.revalidate(itemsToMove);
2005
+ }
2006
+ startAutoScroll() {
2007
+ if (this.autoScrollAnimationId !== null)
2008
+ return;
2009
+ const editor = this.querySelector('#editor');
2010
+ if (!editor)
2011
+ return;
2012
+ const tick = () => {
2013
+ if (!this.isDragging || !this.lastMouseEvent) {
2014
+ this.autoScrollAnimationId = null;
2015
+ return;
2016
+ }
2017
+ const editorRect = editor.getBoundingClientRect();
2018
+ const mouseX = this.lastMouseEvent.clientX;
2019
+ const mouseY = this.lastMouseEvent.clientY;
2020
+ let scrollDx = 0;
2021
+ let scrollDy = 0;
2022
+ // Left edge
2023
+ const distFromLeft = mouseX - editorRect.left;
2024
+ if (distFromLeft >= 0 && distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
2025
+ const ratio = 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE;
2026
+ scrollDx = -(ratio * AUTO_SCROLL_MAX_SPEED);
2027
+ }
2028
+ // Right edge
2029
+ const distFromRight = editorRect.right - mouseX;
2030
+ if (distFromRight >= 0 && distFromRight < AUTO_SCROLL_EDGE_ZONE) {
2031
+ const ratio = 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE;
2032
+ scrollDx = ratio * AUTO_SCROLL_MAX_SPEED;
2033
+ }
2034
+ // Top edge
2035
+ const distFromTop = mouseY - editorRect.top;
2036
+ if (distFromTop >= 0 && distFromTop < AUTO_SCROLL_EDGE_ZONE) {
2037
+ const ratio = 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE;
2038
+ scrollDy = -(ratio * AUTO_SCROLL_MAX_SPEED);
2039
+ }
2040
+ // Bottom edge
2041
+ const distFromBottom = editorRect.bottom - mouseY;
2042
+ if (distFromBottom >= 0 && distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
2043
+ const ratio = 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE;
2044
+ scrollDy = ratio * AUTO_SCROLL_MAX_SPEED;
2045
+ }
2046
+ if (scrollDx !== 0 || scrollDy !== 0) {
2047
+ const beforeScrollLeft = editor.scrollLeft;
2048
+ const beforeScrollTop = editor.scrollTop;
2049
+ // Expand canvas if scrolling toward bottom/right edges
2050
+ // Convert from scroll space to canvas space for expandCanvas
2051
+ if (scrollDx > 0 || scrollDy > 0) {
2052
+ const neededWidth = (editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
2053
+ const neededHeight = (editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
2054
+ getStore().getState().expandCanvas(neededWidth, neededHeight);
2055
+ }
2056
+ editor.scrollLeft += scrollDx;
2057
+ editor.scrollTop += scrollDy;
2058
+ // Track actual scroll delta (browser clamps at boundaries)
2059
+ const actualDx = editor.scrollLeft - beforeScrollLeft;
2060
+ const actualDy = editor.scrollTop - beforeScrollTop;
2061
+ this.autoScrollDeltaX += actualDx;
2062
+ this.autoScrollDeltaY += actualDy;
2063
+ if (actualDx !== 0 || actualDy !== 0) {
2064
+ this.updateDragPositions();
2065
+ }
2066
+ }
2067
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
2068
+ };
2069
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
2070
+ }
2071
+ stopAutoScroll() {
2072
+ if (this.autoScrollAnimationId !== null) {
2073
+ cancelAnimationFrame(this.autoScrollAnimationId);
2074
+ this.autoScrollAnimationId = null;
1561
2075
  }
1562
2076
  }
1563
2077
  handleMouseUp(event) {
@@ -1577,10 +2091,14 @@ export class Editor extends RapidElement {
1577
2091
  // Handle item drag completion
1578
2092
  if (!this.isMouseDown || !this.currentDragItem)
1579
2093
  return;
2094
+ this.stopAutoScroll();
1580
2095
  // If we were actually dragging, handle the drag end
1581
2096
  if (this.isDragging) {
1582
- const deltaX = event.clientX - this.dragStartPos.x;
1583
- const deltaY = event.clientY - this.dragStartPos.y;
2097
+ // Convert screen + scroll delta to canvas delta
2098
+ const deltaX = (event.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
2099
+ this.zoom;
2100
+ const deltaY = (event.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
2101
+ this.zoom;
1584
2102
  // Determine what items were moved
1585
2103
  const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
1586
2104
  this.selectedItems.size > 1
@@ -1635,6 +2153,9 @@ export class Editor extends RapidElement {
1635
2153
  this.isMouseDown = false;
1636
2154
  this.currentDragItem = null;
1637
2155
  this.canvasMouseDown = false;
2156
+ this.autoScrollDeltaX = 0;
2157
+ this.autoScrollDeltaY = 0;
2158
+ this.lastMouseEvent = null;
1638
2159
  }
1639
2160
  updateCanvasSize() {
1640
2161
  var _b;
@@ -1652,9 +2173,9 @@ export class Editor extends RapidElement {
1652
2173
  if (ui && ui.position) {
1653
2174
  const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
1654
2175
  if (nodeElement) {
1655
- const rect = nodeElement.getBoundingClientRect();
1656
- maxWidth = Math.max(maxWidth, ui.position.left + rect.width);
1657
- maxHeight = Math.max(maxHeight, ui.position.top + rect.height);
2176
+ // Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
2177
+ maxWidth = Math.max(maxWidth, ui.position.left + nodeElement.offsetWidth);
2178
+ maxHeight = Math.max(maxHeight, ui.position.top + nodeElement.offsetHeight);
1658
2179
  }
1659
2180
  }
1660
2181
  });
@@ -1701,18 +2222,19 @@ export class Editor extends RapidElement {
1701
2222
  return;
1702
2223
  }
1703
2224
  const canvasRect = canvas.getBoundingClientRect();
1704
- const relativeX = event.clientX - canvasRect.left - 10;
1705
- const relativeY = event.clientY - canvasRect.top - 10;
2225
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
2226
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
1706
2227
  // Snap position to grid
1707
2228
  const snappedLeft = snapToGrid(relativeX);
1708
2229
  const snappedTop = snapToGrid(relativeY);
1709
2230
  // Show the canvas menu at the mouse position (use viewport coordinates)
1710
2231
  const canvasMenu = this.querySelector('temba-canvas-menu');
1711
2232
  if (canvasMenu) {
2233
+ const hasNodes = this.definition && this.definition.nodes.length > 0;
1712
2234
  canvasMenu.show(event.clientX, event.clientY, {
1713
2235
  x: snappedLeft,
1714
2236
  y: snappedTop
1715
- });
2237
+ }, true, hasNodes);
1716
2238
  }
1717
2239
  }
1718
2240
  handleEmptyFlowClick(event) {
@@ -1737,6 +2259,10 @@ export class Editor extends RapidElement {
1737
2259
  handleCanvasMenuSelection(event) {
1738
2260
  const selection = event.detail;
1739
2261
  const store = getStore();
2262
+ if (selection.action === 'reflow') {
2263
+ this.performReflow();
2264
+ return;
2265
+ }
1740
2266
  if (selection.action === 'sticky') {
1741
2267
  // Create new sticky note
1742
2268
  store.getState().createStickyNote({
@@ -2063,8 +2589,9 @@ export class Editor extends RapidElement {
2063
2589
  (_d = getStore()) === null || _d === void 0 ? void 0 : _d.getState().updateNodeUIConfig(updatedNode.uuid, uiConfig);
2064
2590
  }
2065
2591
  // Check for collisions and reflow in case node size changed
2592
+ const nodeUuid = updatedNode.uuid;
2066
2593
  requestAnimationFrame(() => {
2067
- this.checkCollisionsAndReflow([this.editingNode.uuid]);
2594
+ this.checkCollisionsAndReflow([nodeUuid]);
2068
2595
  });
2069
2596
  }
2070
2597
  }
@@ -2094,11 +2621,9 @@ export class Editor extends RapidElement {
2094
2621
  if (!canvas)
2095
2622
  return { left: 0, top: 0 };
2096
2623
  const canvasRect = canvas.getBoundingClientRect();
2097
- // calculate position relative to canvas
2098
- // canvasRect gives us the canvas position in the viewport, which already accounts for scroll
2099
- // so we just need mouseX/Y - canvasRect.left/top to get position within canvas
2100
- const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
2101
- const top = mouseY - canvasRect.top - DROP_PREVIEW_OFFSET_Y;
2624
+ // Convert viewport coordinates to canvas coordinates, accounting for zoom
2625
+ const left = (mouseX - canvasRect.left) / this.zoom - DROP_PREVIEW_OFFSET_X;
2626
+ const top = (mouseY - canvasRect.top) / this.zoom - DROP_PREVIEW_OFFSET_Y;
2102
2627
  // Apply grid snapping only if requested (for final drop position)
2103
2628
  if (applyGridSnapping) {
2104
2629
  return {
@@ -3154,13 +3679,14 @@ export class Editor extends RapidElement {
3154
3679
  const editorRect = editor.getBoundingClientRect();
3155
3680
  const editorCenterX = editorRect.width / 2;
3156
3681
  const editorCenterY = editorRect.height / 2;
3157
- // Get node position relative to the editor's scroll container
3158
- const nodeRect = nodeElement.getBoundingClientRect();
3159
- const nodeCenterX = nodeElement.offsetLeft + nodeRect.width / 2;
3160
- const nodeCenterY = nodeElement.offsetTop + nodeRect.height / 2;
3161
- // Calculate the scroll position needed to center the node
3162
- const targetScrollX = nodeCenterX - editorCenterX;
3163
- const targetScrollY = nodeCenterY - editorCenterY;
3682
+ // Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
3683
+ const nodeCenterX = nodeElement.offsetLeft + nodeElement.offsetWidth / 2;
3684
+ const nodeCenterY = nodeElement.offsetTop + nodeElement.offsetHeight / 2;
3685
+ // Calculate the scroll position needed to center the node.
3686
+ // Multiply by zoom because scroll operates in visual (transformed) space
3687
+ // while offsetLeft/offsetTop are in layout space.
3688
+ const targetScrollX = nodeCenterX * this.zoom - editorCenterX;
3689
+ const targetScrollY = nodeCenterY * this.zoom - editorCenterY;
3164
3690
  // Smooth scroll the editor container to the target position
3165
3691
  editor.scrollTo({
3166
3692
  left: Math.max(0, targetScrollX),
@@ -3206,8 +3732,9 @@ export class Editor extends RapidElement {
3206
3732
  <div
3207
3733
  id="grid"
3208
3734
  class="${this.viewingRevision ? 'viewing-revision' : ''}"
3209
- style="min-width:100%;width:${this.canvasSize
3210
- .width}px; height:${this.canvasSize.height}px"
3735
+ style="min-width:${100 / this.zoom}%;min-height:${100 /
3736
+ this.zoom}%;width:${this.canvasSize.width}px; height:${this
3737
+ .canvasSize.height}px;transform:scale(${this.zoom})"
3211
3738
  >
3212
3739
  <div
3213
3740
  id="canvas"
@@ -3273,6 +3800,55 @@ export class Editor extends RapidElement {
3273
3800
  <div class="save-indicator ${this.isSaving ? 'visible' : ''}">
3274
3801
  <temba-loading units="3" size="8"></temba-loading>
3275
3802
  </div>
3803
+ <div class="zoom-controls">
3804
+ <button
3805
+ @click=${this.zoomToFit}
3806
+ ?disabled=${this.zoomFitted}
3807
+ title="Zoom to fit"
3808
+ >
3809
+ <temba-icon name=${Icon.zoom_fit} size="1"></temba-icon>
3810
+ </button>
3811
+ <div class="zoom-divider"></div>
3812
+ <button
3813
+ @click=${this.zoomOut}
3814
+ ?disabled=${this.zoom <= 0.1}
3815
+ title="Zoom out"
3816
+ >
3817
+
3818
+ </button>
3819
+ <span class="zoom-level">${Math.round(this.zoom * 100)}%</span>
3820
+ <button
3821
+ @click=${this.zoomIn}
3822
+ ?disabled=${this.zoom >= 1.0}
3823
+ title="Zoom in"
3824
+ >
3825
+ +
3826
+ </button>
3827
+ <div class="zoom-divider"></div>
3828
+ <button
3829
+ @click=${this.zoomToFull}
3830
+ ?disabled=${this.zoom >= 1.0}
3831
+ title="Zoom to 100%"
3832
+ >
3833
+ <temba-icon name=${Icon.zoom_in} size="1"></temba-icon>
3834
+ </button>
3835
+ </div>
3836
+ ${this.reflowUnsaved
3837
+ ? html `<div class="reflow-card">
3838
+ <div class="reflow-top">
3839
+ <span class="reflow-label">Unsaved layout changes</span>
3840
+ <button
3841
+ class="reflow-discard"
3842
+ @click=${this.handleReflowDiscard}
3843
+ >
3844
+ Discard
3845
+ </button>
3846
+ </div>
3847
+ <div class="reflow-meter">
3848
+ <div class="reflow-meter-fill"></div>
3849
+ </div>
3850
+ </div>`
3851
+ : ''}
3276
3852
  </div>
3277
3853
 
3278
3854
  ${this.editingNode || this.editingAction
@@ -3409,6 +3985,18 @@ __decorate([
3409
3985
  __decorate([
3410
3986
  state()
3411
3987
  ], Editor.prototype, "saveError", void 0);
3988
+ __decorate([
3989
+ state()
3990
+ ], Editor.prototype, "zoom", void 0);
3991
+ __decorate([
3992
+ state()
3993
+ ], Editor.prototype, "zoomFitted", void 0);
3994
+ __decorate([
3995
+ state()
3996
+ ], Editor.prototype, "reflowPending", void 0);
3997
+ __decorate([
3998
+ state()
3999
+ ], Editor.prototype, "reflowUnsaved", void 0);
3412
4000
  __decorate([
3413
4001
  state()
3414
4002
  ], Editor.prototype, "editingNode", void 0);