@nyaruka/temba-components 0.141.0 → 0.142.0

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 (206) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/static/svg/index.svg +1 -1
  3. package/dist/temba-components.js +859 -656
  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 +665 -67
  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/live/ContactChat.js +10 -1
  38. package/out-tsc/src/live/ContactChat.js.map +1 -1
  39. package/out-tsc/src/version.js +9 -0
  40. package/out-tsc/src/version.js.map +1 -0
  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-contact-chat.test.js +12 -0
  44. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  45. package/out-tsc/test/temba-flow-collision.test.js +25 -0
  46. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  47. package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
  48. package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
  49. package/out-tsc/test/temba-flow-editor.test.js +164 -1
  50. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  51. package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
  52. package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
  53. package/out-tsc/test/temba-flow-plumber.test.js +31 -0
  54. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  55. package/out-tsc/test/temba-flow-reflow.test.js +472 -0
  56. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
  57. package/out-tsc/test/temba-sortable-list.test.js +93 -0
  58. package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
  59. package/package.json +1 -1
  60. package/rollup.components.mjs +7 -1
  61. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  62. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  63. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
  67. package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
  68. package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
  69. package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
  70. package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
  71. package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
  72. package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
  73. package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
  74. package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
  75. package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
  76. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  77. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  78. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  79. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  80. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  81. package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
  82. package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
  83. package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
  84. package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
  85. package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
  86. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  87. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  88. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  89. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  90. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  91. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  92. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  93. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  94. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  95. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  96. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  97. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  98. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  99. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  100. package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
  101. package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
  102. package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
  103. package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
  104. package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
  105. package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
  106. package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
  107. package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
  108. package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
  109. package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
  110. package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
  111. package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
  112. package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
  113. package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
  114. package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
  115. package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
  116. package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
  117. package/screenshots/truth/list/fields-dragging.png +0 -0
  118. package/screenshots/truth/list/sortable-dragging.png +0 -0
  119. package/screenshots/truth/modax/simple.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  124. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  125. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  126. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  127. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  128. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  129. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  130. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  131. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  132. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  141. package/src/Icons.ts +3 -1
  142. package/src/display/Button.ts +2 -2
  143. package/src/display/FloatingTab.ts +1 -1
  144. package/src/flow/CanvasMenu.ts +28 -3
  145. package/src/flow/CanvasNode.ts +7 -2
  146. package/src/flow/Editor.ts +769 -76
  147. package/src/flow/NodeEditor.ts +8 -4
  148. package/src/flow/Plumber.ts +65 -35
  149. package/src/flow/actions/send_msg.ts +2 -1
  150. package/src/flow/nodes/wait_for_response.ts +1 -1
  151. package/src/flow/reflow.ts +534 -0
  152. package/src/flow/types.ts +1 -0
  153. package/src/flow/utils.ts +19 -3
  154. package/src/form/Compose.ts +5 -0
  155. package/src/form/FieldRenderer.ts +1 -3
  156. package/src/layout/Dialog.ts +2 -0
  157. package/src/list/SortableList.ts +40 -19
  158. package/src/live/ContactChat.ts +10 -1
  159. package/src/store/flow-definition.d.ts +1 -0
  160. package/src/version.ts +10 -0
  161. package/static/svg/index.svg +1 -1
  162. package/static/svg/work/traced/expand-06.svg +1 -0
  163. package/static/svg/work/used/expand-06.svg +3 -0
  164. package/test/temba-canvas-menu.test.ts +55 -0
  165. package/test/temba-contact-chat.test.ts +17 -0
  166. package/test/temba-flow-collision.test.ts +31 -0
  167. package/test/temba-flow-editor-zoom.test.ts +583 -0
  168. package/test/temba-flow-editor.test.ts +211 -1
  169. package/test/temba-flow-node-drag.test.ts +171 -0
  170. package/test/temba-flow-plumber.test.ts +38 -0
  171. package/test/temba-flow-reflow.test.ts +703 -0
  172. package/test/temba-sortable-list.test.ts +120 -0
  173. package/web-dev-server.config.mjs +5 -1
  174. package/web-test-runner.config.mjs +4 -1
  175. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  176. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  177. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  178. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  179. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  180. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  181. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  182. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  183. package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
  184. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  185. package/screenshots/truth/compose/attachments-with-failures.png +0 -0
  186. package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
  187. package/screenshots/truth/contacts/tickets-assignment.png +0 -0
  188. package/screenshots/truth/contacts/tickets.png +0 -0
  189. package/screenshots/truth/flow/editor-basic.png +0 -0
  190. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  191. package/screenshots/truth/formfield/no-errors.png +0 -0
  192. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  193. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  194. package/screenshots/truth/omnibox/selected.png +0 -0
  195. package/screenshots/truth/select/enabled-multi-selection.png +0 -0
  196. package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
  197. package/screenshots/truth/select/endpoint-initial-value.png +0 -0
  198. package/screenshots/truth/select/initial-value.png +0 -0
  199. package/screenshots/truth/select/multi-reorder-final.png +0 -0
  200. package/screenshots/truth/select/multi-reorder-initial.png +0 -0
  201. package/screenshots/truth/select/selected-multi-test.png +0 -0
  202. package/screenshots/truth/select/value-initial.png +0 -0
  203. package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
  204. package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
  205. package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
  206. package/screenshots/truth/webchat/connecting-state.png +0 -0
@@ -8,11 +8,15 @@ import { RapidElement } from '../RapidElement';
8
8
  import { repeat } from 'lit-html/directives/repeat.js';
9
9
  import { CustomEventType } from '../interfaces';
10
10
  import { generateUUID, postJSON, fetchResults, getClasses } from '../utils';
11
- import { formatIssueMessage, getNodeBounds, calculateReflowPositions, snapToGrid } from './utils';
11
+ import { TEMBA_COMPONENTS_VERSION } from '../version';
12
+ import { formatIssueMessage, getNodeBounds, calculateReflowPositions, isRightClick, snapToGrid } from './utils';
12
13
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
14
+ import { calculateLayeredLayout, placeStickyNotes } from './reflow';
15
+ import { FloatingTab } from '../display/FloatingTab';
13
16
  import { ACTION_GROUP_METADATA } from './types';
14
17
  import { Plumber, calculateFlowchartPath, ARROW_LENGTH, ARROW_HALF_WIDTH, CURSOR_GAP } from './Plumber';
15
18
  import { CanvasNode } from './CanvasNode';
19
+ import { Icon } from '../Icons';
16
20
  export function findNodeForExit(definition, exitUuid) {
17
21
  for (const node of definition.nodes) {
18
22
  const exit = node.exits.find((e) => e.uuid === exitUuid);
@@ -24,7 +28,12 @@ export function findNodeForExit(definition, exitUuid) {
24
28
  }
25
29
  const SAVE_QUIET_TIME = 2000;
26
30
  const DRAG_THRESHOLD = 5;
31
+ const AUTO_SCROLL_EDGE_ZONE = 100;
32
+ const AUTO_SCROLL_MAX_SPEED = 15;
27
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;
28
37
  // Offset for positioning dropped action node relative to mouse cursor
29
38
  // Keep small to make drop location close to cursor position
30
39
  const DROP_PREVIEW_OFFSET_X = 20;
@@ -38,6 +47,12 @@ export class Editor extends RapidElement {
38
47
  get dragging() {
39
48
  return this.isDragging;
40
49
  }
50
+ clearReflowAutoSaveTimer() {
51
+ if (this.reflowAutoSaveTimer !== null) {
52
+ clearTimeout(this.reflowAutoSaveTimer);
53
+ this.reflowAutoSaveTimer = null;
54
+ }
55
+ }
41
56
  getAvailableLanguages() {
42
57
  var _b, _c, _d;
43
58
  // Use languages from workspace if available
@@ -98,6 +113,7 @@ export class Editor extends RapidElement {
98
113
  width: 100%;
99
114
  display: flex;
100
115
  padding-top: 20px;
116
+ transform-origin: 0 0;
101
117
  }
102
118
 
103
119
  #canvas {
@@ -607,7 +623,7 @@ export class Editor extends RapidElement {
607
623
  .save-indicator {
608
624
  position: absolute;
609
625
  top: 8px;
610
- right: 16px;
626
+ right: 240px;
611
627
  padding: 6px 10px;
612
628
  z-index: 10000;
613
629
  pointer-events: none;
@@ -618,6 +634,135 @@ export class Editor extends RapidElement {
618
634
  .save-indicator.visible {
619
635
  opacity: 1;
620
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
+ }
621
766
  `;
622
767
  }
623
768
  constructor() {
@@ -634,6 +779,11 @@ export class Editor extends RapidElement {
634
779
  this.dragStartPos = { x: 0, y: 0 };
635
780
  this.currentDragItem = null;
636
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;
637
787
  // Selection state
638
788
  this.selectedItems = new Set();
639
789
  this.isSelecting = false;
@@ -662,6 +812,12 @@ export class Editor extends RapidElement {
662
812
  this.isLoadingRevisions = false;
663
813
  this.isSaving = false;
664
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;
665
821
  this.preRevertState = null;
666
822
  this.translationCache = new Map();
667
823
  // NodeEditor state - handles both node and action editing
@@ -689,11 +845,13 @@ export class Editor extends RapidElement {
689
845
  this.boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
690
846
  this.boundKeyDown = this.handleKeyDown.bind(this);
691
847
  this.boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
848
+ this.boundWheel = this.handleWheel.bind(this);
692
849
  }
693
850
  firstUpdated(changes) {
694
851
  super.firstUpdated(changes);
695
852
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
696
853
  this.setupGlobalEventListeners();
854
+ this.updateZoomControlPositioning();
697
855
  if (changes.has('flow')) {
698
856
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
699
857
  this.fetchRevisions();
@@ -747,10 +905,10 @@ export class Editor extends RapidElement {
747
905
  const canvas = this.querySelector('#canvas');
748
906
  if (canvas) {
749
907
  const canvasRect = canvas.getBoundingClientRect();
750
- const menuX = canvasRect.left + snappedPosition.left - 40;
908
+ const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
751
909
  const menuY = isDragUp
752
- ? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
753
- : 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
754
912
  const canvasMenu = this.querySelector('temba-canvas-menu');
755
913
  if (canvasMenu) {
756
914
  canvasMenu.show(menuX, menuY, {
@@ -824,8 +982,21 @@ export class Editor extends RapidElement {
824
982
  }
825
983
  if (changes.has('dirtyDate')) {
826
984
  if (this.dirtyDate) {
827
- this.isSaving = true;
828
- 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
+ }
829
1000
  }
830
1001
  }
831
1002
  if (changes.has('saveError') && this.saveError) {
@@ -860,6 +1031,11 @@ export class Editor extends RapidElement {
860
1031
  clearTimeout(this.saveTimer);
861
1032
  }
862
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
+ }
863
1039
  const now = new Date();
864
1040
  const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
865
1041
  if (timeSinceLastChange >= SAVE_QUIET_TIME) {
@@ -871,8 +1047,17 @@ export class Editor extends RapidElement {
871
1047
  }
872
1048
  }, SAVE_QUIET_TIME);
873
1049
  }
1050
+ definitionForSave(definition) {
1051
+ return {
1052
+ ...definition,
1053
+ _ui: {
1054
+ ...definition._ui,
1055
+ editor: TEMBA_COMPONENTS_VERSION
1056
+ }
1057
+ };
1058
+ }
874
1059
  saveChanges(definitionOverride) {
875
- const definition = definitionOverride || this.definition;
1060
+ const definition = this.definitionForSave(definitionOverride || this.definition);
876
1061
  this.isSaving = true;
877
1062
  return getStore()
878
1063
  .postJSON(`/flow/revisions/${this.flow}/`, definition)
@@ -988,6 +1173,7 @@ export class Editor extends RapidElement {
988
1173
  }
989
1174
  disconnectedCallback() {
990
1175
  super.disconnectedCallback();
1176
+ this.stopAutoScroll();
991
1177
  if (this.saveTimer !== null) {
992
1178
  clearTimeout(this.saveTimer);
993
1179
  this.saveTimer = null;
@@ -996,6 +1182,7 @@ export class Editor extends RapidElement {
996
1182
  clearTimeout(this.activityTimer);
997
1183
  this.activityTimer = null;
998
1184
  }
1185
+ this.clearReflowAutoSaveTimer();
999
1186
  document.removeEventListener('mousemove', this.boundMouseMove);
1000
1187
  document.removeEventListener('mouseup', this.boundMouseUp);
1001
1188
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
@@ -1004,6 +1191,10 @@ export class Editor extends RapidElement {
1004
1191
  if (canvas) {
1005
1192
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1006
1193
  }
1194
+ const editor = this.querySelector('#editor');
1195
+ if (editor) {
1196
+ editor.removeEventListener('wheel', this.boundWheel);
1197
+ }
1007
1198
  // Clear all flow-specific data from the store so stale data
1008
1199
  // isn't briefly visible when a different flow is opened.
1009
1200
  zustand.getState().clearFlowData();
@@ -1017,6 +1208,10 @@ export class Editor extends RapidElement {
1017
1208
  if (canvas) {
1018
1209
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1019
1210
  }
1211
+ const editor = this.querySelector('#editor');
1212
+ if (editor) {
1213
+ editor.addEventListener('wheel', this.boundWheel, { passive: false });
1214
+ }
1020
1215
  // Listen for action edit requests from flow nodes
1021
1216
  this.addEventListener(CustomEventType.ActionEditRequested, this.handleActionEditRequested.bind(this));
1022
1217
  // Listen for add action requests from flow nodes
@@ -1064,8 +1259,7 @@ export class Editor extends RapidElement {
1064
1259
  }
1065
1260
  }
1066
1261
  handleMouseDown(event) {
1067
- // ignore right clicks
1068
- if (event.button !== 0)
1262
+ if (isRightClick(event))
1069
1263
  return;
1070
1264
  if (this.isReadOnly())
1071
1265
  return;
@@ -1106,8 +1300,7 @@ export class Editor extends RapidElement {
1106
1300
  }
1107
1301
  handleGlobalMouseDown(event) {
1108
1302
  var _b;
1109
- // ignore right clicks
1110
- if (event.button !== 0)
1303
+ if (isRightClick(event))
1111
1304
  return;
1112
1305
  // Check if the click is within our canvas
1113
1306
  const canvasRect = (_b = this.querySelector('#grid')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
@@ -1155,8 +1348,8 @@ export class Editor extends RapidElement {
1155
1348
  if (canvasRect) {
1156
1349
  // Clear current selection
1157
1350
  this.selectedItems.clear();
1158
- const relativeX = event.clientX - canvasRect.left;
1159
- const relativeY = event.clientY - canvasRect.top;
1351
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1352
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1160
1353
  this.selectionBox = {
1161
1354
  startX: relativeX,
1162
1355
  startY: relativeY,
@@ -1178,6 +1371,135 @@ export class Editor extends RapidElement {
1178
1371
  this.requestUpdate();
1179
1372
  }
1180
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
+ }
1181
1503
  showDeleteConfirmation() {
1182
1504
  const itemCount = this.selectedItems.size;
1183
1505
  const itemType = itemCount === 1 ? 'item' : 'items';
@@ -1202,6 +1524,127 @@ export class Editor extends RapidElement {
1202
1524
  document.body.removeChild(dialog);
1203
1525
  });
1204
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
+ }
1205
1648
  deleteNodes(uuids) {
1206
1649
  // Remove nodes from the definition - CanvasNode will handle plumber cleanup
1207
1650
  if (uuids.length > 0) {
@@ -1223,8 +1666,8 @@ export class Editor extends RapidElement {
1223
1666
  const canvasRect = (_b = this.querySelector('#canvas')) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
1224
1667
  if (!canvasRect)
1225
1668
  return;
1226
- const relativeX = event.clientX - canvasRect.left;
1227
- const relativeY = event.clientY - canvasRect.top;
1669
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1670
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1228
1671
  this.selectionBox = {
1229
1672
  ...this.selectionBox,
1230
1673
  endX: relativeX,
@@ -1249,13 +1692,12 @@ export class Editor extends RapidElement {
1249
1692
  if (nodeElement) {
1250
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;
1251
1694
  if (position) {
1252
- const rect = nodeElement.getBoundingClientRect();
1253
1695
  const canvasRect = (_d = this.querySelector('#canvas')) === null || _d === void 0 ? void 0 : _d.getBoundingClientRect();
1254
1696
  if (canvasRect) {
1255
1697
  const nodeLeft = position.left;
1256
1698
  const nodeTop = position.top;
1257
- const nodeRight = nodeLeft + rect.width;
1258
- const nodeBottom = nodeTop + rect.height;
1699
+ const nodeRight = nodeLeft + nodeElement.offsetWidth;
1700
+ const nodeBottom = nodeTop + nodeElement.offsetHeight;
1259
1701
  // Check if selection box intersects with node
1260
1702
  if (boxLeft < nodeRight &&
1261
1703
  boxRight > nodeLeft &&
@@ -1480,8 +1922,8 @@ export class Editor extends RapidElement {
1480
1922
  const canvas = this.querySelector('#canvas');
1481
1923
  if (canvas) {
1482
1924
  const canvasRect = canvas.getBoundingClientRect();
1483
- const relativeX = event.clientX - canvasRect.left;
1484
- const relativeY = event.clientY - canvasRect.top;
1925
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1926
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1485
1927
  const placeholderWidth = 200;
1486
1928
  const placeholderHeight = 64;
1487
1929
  const arrowLength = ARROW_LENGTH;
@@ -1517,37 +1959,119 @@ export class Editor extends RapidElement {
1517
1959
  // Handle item dragging
1518
1960
  if (!this.isMouseDown || !this.currentDragItem)
1519
1961
  return;
1520
- const deltaX = event.clientX - this.dragStartPos.x;
1521
- 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;
1522
1965
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1523
1966
  // Only start dragging if we've moved beyond the threshold
1524
1967
  if (!this.isDragging && distance > DRAG_THRESHOLD) {
1525
1968
  this.isDragging = true;
1969
+ this.startAutoScroll();
1526
1970
  }
1527
1971
  // If we're actually dragging, update positions
1528
1972
  if (this.isDragging) {
1529
- // Determine what items to move
1530
- const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
1531
- this.selectedItems.size > 1
1532
- ? Array.from(this.selectedItems)
1533
- : [this.currentDragItem.uuid];
1534
- itemsToMove.forEach((uuid) => {
1535
- const element = this.querySelector(`[uuid="${uuid}"]`);
1536
- if (element) {
1537
- const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1538
- const position = this.getPosition(uuid, type);
1539
- if (position) {
1540
- const newLeft = position.left + deltaX;
1541
- const newTop = position.top + deltaY;
1542
- // Update the visual position during drag
1543
- element.style.left = `${newLeft}px`;
1544
- element.style.top = `${newTop}px`;
1545
- // Add dragging class to ensure highest z-index
1546
- element.classList.add('dragging');
1547
- }
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');
1548
2001
  }
1549
- });
1550
- 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;
1551
2075
  }
1552
2076
  }
1553
2077
  handleMouseUp(event) {
@@ -1567,10 +2091,14 @@ export class Editor extends RapidElement {
1567
2091
  // Handle item drag completion
1568
2092
  if (!this.isMouseDown || !this.currentDragItem)
1569
2093
  return;
2094
+ this.stopAutoScroll();
1570
2095
  // If we were actually dragging, handle the drag end
1571
2096
  if (this.isDragging) {
1572
- const deltaX = event.clientX - this.dragStartPos.x;
1573
- 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;
1574
2102
  // Determine what items were moved
1575
2103
  const itemsToMove = this.selectedItems.has(this.currentDragItem.uuid) &&
1576
2104
  this.selectedItems.size > 1
@@ -1625,6 +2153,9 @@ export class Editor extends RapidElement {
1625
2153
  this.isMouseDown = false;
1626
2154
  this.currentDragItem = null;
1627
2155
  this.canvasMouseDown = false;
2156
+ this.autoScrollDeltaX = 0;
2157
+ this.autoScrollDeltaY = 0;
2158
+ this.lastMouseEvent = null;
1628
2159
  }
1629
2160
  updateCanvasSize() {
1630
2161
  var _b;
@@ -1642,9 +2173,9 @@ export class Editor extends RapidElement {
1642
2173
  if (ui && ui.position) {
1643
2174
  const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
1644
2175
  if (nodeElement) {
1645
- const rect = nodeElement.getBoundingClientRect();
1646
- maxWidth = Math.max(maxWidth, ui.position.left + rect.width);
1647
- 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);
1648
2179
  }
1649
2180
  }
1650
2181
  });
@@ -1691,18 +2222,19 @@ export class Editor extends RapidElement {
1691
2222
  return;
1692
2223
  }
1693
2224
  const canvasRect = canvas.getBoundingClientRect();
1694
- const relativeX = event.clientX - canvasRect.left - 10;
1695
- 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;
1696
2227
  // Snap position to grid
1697
2228
  const snappedLeft = snapToGrid(relativeX);
1698
2229
  const snappedTop = snapToGrid(relativeY);
1699
2230
  // Show the canvas menu at the mouse position (use viewport coordinates)
1700
2231
  const canvasMenu = this.querySelector('temba-canvas-menu');
1701
2232
  if (canvasMenu) {
2233
+ const hasNodes = this.definition && this.definition.nodes.length > 0;
1702
2234
  canvasMenu.show(event.clientX, event.clientY, {
1703
2235
  x: snappedLeft,
1704
2236
  y: snappedTop
1705
- });
2237
+ }, true, hasNodes);
1706
2238
  }
1707
2239
  }
1708
2240
  handleEmptyFlowClick(event) {
@@ -1727,6 +2259,10 @@ export class Editor extends RapidElement {
1727
2259
  handleCanvasMenuSelection(event) {
1728
2260
  const selection = event.detail;
1729
2261
  const store = getStore();
2262
+ if (selection.action === 'reflow') {
2263
+ this.performReflow();
2264
+ return;
2265
+ }
1730
2266
  if (selection.action === 'sticky') {
1731
2267
  // Create new sticky note
1732
2268
  store.getState().createStickyNote({
@@ -2053,8 +2589,9 @@ export class Editor extends RapidElement {
2053
2589
  (_d = getStore()) === null || _d === void 0 ? void 0 : _d.getState().updateNodeUIConfig(updatedNode.uuid, uiConfig);
2054
2590
  }
2055
2591
  // Check for collisions and reflow in case node size changed
2592
+ const nodeUuid = updatedNode.uuid;
2056
2593
  requestAnimationFrame(() => {
2057
- this.checkCollisionsAndReflow([this.editingNode.uuid]);
2594
+ this.checkCollisionsAndReflow([nodeUuid]);
2058
2595
  });
2059
2596
  }
2060
2597
  }
@@ -2084,11 +2621,9 @@ export class Editor extends RapidElement {
2084
2621
  if (!canvas)
2085
2622
  return { left: 0, top: 0 };
2086
2623
  const canvasRect = canvas.getBoundingClientRect();
2087
- // calculate position relative to canvas
2088
- // canvasRect gives us the canvas position in the viewport, which already accounts for scroll
2089
- // so we just need mouseX/Y - canvasRect.left/top to get position within canvas
2090
- const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
2091
- 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;
2092
2627
  // Apply grid snapping only if requested (for final drop position)
2093
2628
  if (applyGridSnapping) {
2094
2629
  return {
@@ -3144,13 +3679,14 @@ export class Editor extends RapidElement {
3144
3679
  const editorRect = editor.getBoundingClientRect();
3145
3680
  const editorCenterX = editorRect.width / 2;
3146
3681
  const editorCenterY = editorRect.height / 2;
3147
- // Get node position relative to the editor's scroll container
3148
- const nodeRect = nodeElement.getBoundingClientRect();
3149
- const nodeCenterX = nodeElement.offsetLeft + nodeRect.width / 2;
3150
- const nodeCenterY = nodeElement.offsetTop + nodeRect.height / 2;
3151
- // Calculate the scroll position needed to center the node
3152
- const targetScrollX = nodeCenterX - editorCenterX;
3153
- 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;
3154
3690
  // Smooth scroll the editor container to the target position
3155
3691
  editor.scrollTo({
3156
3692
  left: Math.max(0, targetScrollX),
@@ -3196,8 +3732,9 @@ export class Editor extends RapidElement {
3196
3732
  <div
3197
3733
  id="grid"
3198
3734
  class="${this.viewingRevision ? 'viewing-revision' : ''}"
3199
- style="min-width:100%;width:${this.canvasSize
3200
- .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})"
3201
3738
  >
3202
3739
  <div
3203
3740
  id="canvas"
@@ -3263,6 +3800,55 @@ export class Editor extends RapidElement {
3263
3800
  <div class="save-indicator ${this.isSaving ? 'visible' : ''}">
3264
3801
  <temba-loading units="3" size="8"></temba-loading>
3265
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
+ : ''}
3266
3852
  </div>
3267
3853
 
3268
3854
  ${this.editingNode || this.editingAction
@@ -3399,6 +3985,18 @@ __decorate([
3399
3985
  __decorate([
3400
3986
  state()
3401
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);
3402
4000
  __decorate([
3403
4001
  state()
3404
4002
  ], Editor.prototype, "editingNode", void 0);