@nyaruka/temba-components 0.141.1 → 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 (193) hide show
  1. package/CHANGELOG.md +15 -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/test/temba-canvas-menu.test.js +44 -0
  38. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  39. package/out-tsc/test/temba-flow-collision.test.js +25 -0
  40. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  41. package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
  42. package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
  43. package/out-tsc/test/temba-flow-editor.test.js +145 -1
  44. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  45. package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
  46. package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
  47. package/out-tsc/test/temba-flow-plumber.test.js +31 -0
  48. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  49. package/out-tsc/test/temba-flow-reflow.test.js +472 -0
  50. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
  51. package/out-tsc/test/temba-sortable-list.test.js +93 -0
  52. package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  55. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  56. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  57. package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
  58. package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
  59. package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
  60. package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
  61. package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
  62. package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
  63. package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
  67. package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
  68. package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
  69. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  70. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  71. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  72. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  73. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  74. package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
  75. package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
  76. package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
  77. package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
  78. package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
  79. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  80. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  81. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  82. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  83. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  84. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  85. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  86. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  87. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  88. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  89. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  90. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  92. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  93. package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
  94. package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
  95. package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
  96. package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
  97. package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
  98. package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
  99. package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
  100. package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
  101. package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
  102. package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
  103. package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
  104. package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
  105. package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
  106. package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
  107. package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
  108. package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
  109. package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
  110. package/screenshots/truth/list/fields-dragging.png +0 -0
  111. package/screenshots/truth/list/sortable-dragging.png +0 -0
  112. package/screenshots/truth/modax/simple.png +0 -0
  113. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  114. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  115. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  116. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  117. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  118. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  122. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  123. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  124. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  125. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  126. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  127. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  128. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  129. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  134. package/src/Icons.ts +3 -1
  135. package/src/display/Button.ts +2 -2
  136. package/src/display/FloatingTab.ts +1 -1
  137. package/src/flow/CanvasMenu.ts +28 -3
  138. package/src/flow/CanvasNode.ts +7 -2
  139. package/src/flow/Editor.ts +755 -75
  140. package/src/flow/NodeEditor.ts +8 -4
  141. package/src/flow/Plumber.ts +65 -35
  142. package/src/flow/actions/send_msg.ts +2 -1
  143. package/src/flow/nodes/wait_for_response.ts +1 -1
  144. package/src/flow/reflow.ts +534 -0
  145. package/src/flow/types.ts +1 -0
  146. package/src/flow/utils.ts +19 -3
  147. package/src/form/Compose.ts +5 -0
  148. package/src/form/FieldRenderer.ts +1 -3
  149. package/src/layout/Dialog.ts +2 -0
  150. package/src/list/SortableList.ts +40 -19
  151. package/static/svg/index.svg +1 -1
  152. package/static/svg/work/traced/expand-06.svg +1 -0
  153. package/static/svg/work/used/expand-06.svg +3 -0
  154. package/test/temba-canvas-menu.test.ts +55 -0
  155. package/test/temba-flow-collision.test.ts +31 -0
  156. package/test/temba-flow-editor-zoom.test.ts +583 -0
  157. package/test/temba-flow-editor.test.ts +187 -1
  158. package/test/temba-flow-node-drag.test.ts +171 -0
  159. package/test/temba-flow-plumber.test.ts +38 -0
  160. package/test/temba-flow-reflow.test.ts +703 -0
  161. package/test/temba-sortable-list.test.ts +120 -0
  162. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  163. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  164. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  165. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  166. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  167. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  168. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  169. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  170. package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
  171. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  172. package/screenshots/truth/compose/attachments-with-failures.png +0 -0
  173. package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
  174. package/screenshots/truth/contacts/tickets-assignment.png +0 -0
  175. package/screenshots/truth/contacts/tickets.png +0 -0
  176. package/screenshots/truth/flow/editor-basic.png +0 -0
  177. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  178. package/screenshots/truth/formfield/no-errors.png +0 -0
  179. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  180. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  181. package/screenshots/truth/omnibox/selected.png +0 -0
  182. package/screenshots/truth/select/enabled-multi-selection.png +0 -0
  183. package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
  184. package/screenshots/truth/select/endpoint-initial-value.png +0 -0
  185. package/screenshots/truth/select/initial-value.png +0 -0
  186. package/screenshots/truth/select/multi-reorder-final.png +0 -0
  187. package/screenshots/truth/select/multi-reorder-initial.png +0 -0
  188. package/screenshots/truth/select/selected-multi-test.png +0 -0
  189. package/screenshots/truth/select/value-initial.png +0 -0
  190. package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
  191. package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
  192. package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
  193. package/screenshots/truth/webchat/connecting-state.png +0 -0
@@ -31,10 +31,13 @@ import {
31
31
  formatIssueMessage,
32
32
  getNodeBounds,
33
33
  calculateReflowPositions,
34
+ isRightClick,
34
35
  NodeBounds,
35
36
  snapToGrid
36
37
  } from './utils';
37
38
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
39
+ import { calculateLayeredLayout, placeStickyNotes } from './reflow';
40
+ import { FloatingTab } from '../display/FloatingTab';
38
41
 
39
42
  interface Revision {
40
43
  id: number;
@@ -65,6 +68,7 @@ import { Dialog } from '../layout/Dialog';
65
68
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
66
69
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
67
70
  import { FloatingWindow } from '../layout/FloatingWindow';
71
+ import { Icon } from '../Icons';
68
72
 
69
73
  export function findNodeForExit(
70
74
  definition: FlowDefinition,
@@ -96,6 +100,8 @@ export interface SelectionBox {
96
100
  }
97
101
 
98
102
  const DRAG_THRESHOLD = 5;
103
+ const AUTO_SCROLL_EDGE_ZONE = 100;
104
+ const AUTO_SCROLL_MAX_SPEED = 15;
99
105
 
100
106
  type TranslationType = 'property' | 'category';
101
107
 
@@ -126,6 +132,10 @@ interface LocalizationUpdate {
126
132
 
127
133
  const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
128
134
 
135
+ // How long the reflow auto-save countdown runs (in ms).
136
+ // Used in both the CSS animation and the JS setTimeout.
137
+ const REFLOW_AUTO_SAVE_DELAY = 5000;
138
+
129
139
  // Offset for positioning dropped action node relative to mouse cursor
130
140
  // Keep small to make drop location close to cursor position
131
141
  const DROP_PREVIEW_OFFSET_X = 20;
@@ -200,6 +210,12 @@ export class Editor extends RapidElement {
200
210
  private currentDragItem: DraggableItem | null = null;
201
211
  private startPos = { left: 0, top: 0 };
202
212
 
213
+ // Auto-scroll state
214
+ private autoScrollAnimationId: number | null = null;
215
+ private autoScrollDeltaX = 0;
216
+ private autoScrollDeltaY = 0;
217
+ private lastMouseEvent: MouseEvent | null = null;
218
+
203
219
  // Selection state
204
220
  @state()
205
221
  private selectedItems: Set<string> = new Set();
@@ -273,6 +289,28 @@ export class Editor extends RapidElement {
273
289
  @state()
274
290
  private saveError: string | null = null;
275
291
 
292
+ @state()
293
+ private zoom = 1.0;
294
+
295
+ @state()
296
+ private zoomFitted = false;
297
+
298
+ @state()
299
+ private reflowPending = false;
300
+
301
+ @state()
302
+ private reflowUnsaved = false;
303
+
304
+ private savedReflowPositions: Record<string, FlowPosition> | null = null;
305
+ private reflowAutoSaveTimer: ReturnType<typeof setTimeout> | null = null;
306
+
307
+ private clearReflowAutoSaveTimer(): void {
308
+ if (this.reflowAutoSaveTimer !== null) {
309
+ clearTimeout(this.reflowAutoSaveTimer);
310
+ this.reflowAutoSaveTimer = null;
311
+ }
312
+ }
313
+
276
314
  private preRevertState: {
277
315
  definition: FlowDefinition;
278
316
  dirtyDate: Date | null;
@@ -371,6 +409,7 @@ export class Editor extends RapidElement {
371
409
  private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
372
410
  private boundKeyDown = this.handleKeyDown.bind(this);
373
411
  private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
412
+ private boundWheel = this.handleWheel.bind(this);
374
413
 
375
414
  static get styles() {
376
415
  return css`
@@ -404,6 +443,7 @@ export class Editor extends RapidElement {
404
443
  width: 100%;
405
444
  display: flex;
406
445
  padding-top: 20px;
446
+ transform-origin: 0 0;
407
447
  }
408
448
 
409
449
  #canvas {
@@ -913,7 +953,7 @@ export class Editor extends RapidElement {
913
953
  .save-indicator {
914
954
  position: absolute;
915
955
  top: 8px;
916
- right: 16px;
956
+ right: 240px;
917
957
  padding: 6px 10px;
918
958
  z-index: 10000;
919
959
  pointer-events: none;
@@ -924,6 +964,135 @@ export class Editor extends RapidElement {
924
964
  .save-indicator.visible {
925
965
  opacity: 1;
926
966
  }
967
+
968
+ .zoom-controls {
969
+ position: absolute;
970
+ top: 8px;
971
+ right: 16px;
972
+ z-index: 4999;
973
+ display: flex;
974
+ align-items: center;
975
+ gap: 2px;
976
+ background: white;
977
+ border-radius: var(--curvature);
978
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
979
+ padding: 4px;
980
+ user-select: none;
981
+ }
982
+
983
+ .zoom-controls button {
984
+ width: 28px;
985
+ height: 28px;
986
+ border: none;
987
+ background: transparent;
988
+ border-radius: var(--curvature);
989
+ cursor: pointer;
990
+ display: flex;
991
+ align-items: center;
992
+ justify-content: center;
993
+ padding: 0;
994
+ color: #555;
995
+ font-size: 16px;
996
+ line-height: 1;
997
+ outline: none;
998
+ }
999
+
1000
+ .zoom-controls button:hover {
1001
+ background: rgba(0, 0, 0, 0.06);
1002
+ }
1003
+
1004
+ .zoom-controls button:disabled {
1005
+ opacity: 0.3;
1006
+ cursor: default;
1007
+ background: transparent;
1008
+ }
1009
+
1010
+ .zoom-controls .zoom-level {
1011
+ font-size: 12px;
1012
+ min-width: 40px;
1013
+ text-align: center;
1014
+ color: #555;
1015
+ font-weight: 500;
1016
+ }
1017
+
1018
+ .zoom-controls .zoom-divider {
1019
+ width: 1px;
1020
+ height: 16px;
1021
+ background: #e0e0e0;
1022
+ margin: 0 2px;
1023
+ }
1024
+
1025
+ .reflow-card {
1026
+ position: absolute;
1027
+ top: 16px;
1028
+ left: 50%;
1029
+ transform: translateX(-50%);
1030
+ z-index: 10000;
1031
+ background: rgba(0, 0, 0, 0.65);
1032
+ backdrop-filter: blur(8px);
1033
+ border-radius: 10px;
1034
+ padding: 12px 16px 8px;
1035
+ display: flex;
1036
+ flex-direction: column;
1037
+ gap: 8px;
1038
+ color: white;
1039
+ font-size: 13px;
1040
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1041
+ }
1042
+
1043
+ .reflow-card .reflow-top {
1044
+ display: flex;
1045
+ align-items: center;
1046
+ gap: 10px;
1047
+ }
1048
+
1049
+ .reflow-card .reflow-label {
1050
+ white-space: nowrap;
1051
+ }
1052
+
1053
+ .reflow-card button {
1054
+ border: none;
1055
+ border-radius: 6px;
1056
+ padding: 6px 14px;
1057
+ font-size: 13px;
1058
+ font-weight: 500;
1059
+ cursor: pointer;
1060
+ white-space: nowrap;
1061
+ transition: opacity 0.15s ease;
1062
+ }
1063
+
1064
+ .reflow-card button:hover {
1065
+ opacity: 0.85;
1066
+ }
1067
+
1068
+ .reflow-card .reflow-discard {
1069
+ background: rgba(255, 255, 255, 0.2);
1070
+ color: white;
1071
+ }
1072
+
1073
+ .reflow-meter {
1074
+ height: 3px;
1075
+ border-radius: 2px;
1076
+ background: rgba(255, 255, 255, 0.15);
1077
+ overflow: hidden;
1078
+ }
1079
+
1080
+ .reflow-meter-fill {
1081
+ height: 100%;
1082
+ background: rgba(255, 255, 255, 0.5);
1083
+ border-radius: 2px;
1084
+ animation: reflow-countdown ${unsafeCSS(REFLOW_AUTO_SAVE_DELAY / 1000)}s
1085
+ linear forwards;
1086
+ }
1087
+
1088
+ @keyframes reflow-countdown {
1089
+ from {
1090
+ width: 100%;
1091
+ }
1092
+ to {
1093
+ width: 0%;
1094
+ }
1095
+ }
927
1096
  `;
928
1097
  }
929
1098
 
@@ -937,6 +1106,7 @@ export class Editor extends RapidElement {
937
1106
  super.firstUpdated(changes);
938
1107
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
939
1108
  this.setupGlobalEventListeners();
1109
+ this.updateZoomControlPositioning();
940
1110
  if (changes.has('flow')) {
941
1111
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
942
1112
  this.fetchRevisions();
@@ -1002,10 +1172,10 @@ export class Editor extends RapidElement {
1002
1172
  const canvas = this.querySelector('#canvas');
1003
1173
  if (canvas) {
1004
1174
  const canvasRect = canvas.getBoundingClientRect();
1005
- const menuX = canvasRect.left + snappedPosition.left - 40;
1175
+ const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
1006
1176
  const menuY = isDragUp
1007
- ? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
1008
- : canvasRect.top + snappedPosition.top + 80; // just below placeholder
1177
+ ? canvasRect.top + snappedPosition.top * this.zoom + 74 // just below placeholder bottom
1178
+ : canvasRect.top + snappedPosition.top * this.zoom + 80; // just below placeholder
1009
1179
 
1010
1180
  const canvasMenu = this.querySelector(
1011
1181
  'temba-canvas-menu'
@@ -1104,8 +1274,20 @@ export class Editor extends RapidElement {
1104
1274
 
1105
1275
  if (changes.has('dirtyDate')) {
1106
1276
  if (this.dirtyDate) {
1107
- this.isSaving = true;
1108
- this.debouncedSave();
1277
+ if (this.reflowPending) {
1278
+ // This dirtyDate is from the reflow itself — suppress save
1279
+ this.reflowPending = false;
1280
+ } else {
1281
+ // Normal change — if reflow card was showing, it goes away
1282
+ // because these changes will be included in the save
1283
+ if (this.reflowUnsaved) {
1284
+ this.reflowUnsaved = false;
1285
+ this.savedReflowPositions = null;
1286
+ this.clearReflowAutoSaveTimer();
1287
+ }
1288
+ this.isSaving = true;
1289
+ this.debouncedSave();
1290
+ }
1109
1291
  }
1110
1292
  }
1111
1293
 
@@ -1145,6 +1327,12 @@ export class Editor extends RapidElement {
1145
1327
  }
1146
1328
 
1147
1329
  this.saveTimer = window.setTimeout(() => {
1330
+ // Don't auto-save while a reflow preview is pending user confirmation
1331
+ if (this.reflowUnsaved) {
1332
+ this.saveTimer = null;
1333
+ return;
1334
+ }
1335
+
1148
1336
  const now = new Date();
1149
1337
  const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
1150
1338
 
@@ -1304,6 +1492,7 @@ export class Editor extends RapidElement {
1304
1492
 
1305
1493
  disconnectedCallback(): void {
1306
1494
  super.disconnectedCallback();
1495
+ this.stopAutoScroll();
1307
1496
  if (this.saveTimer !== null) {
1308
1497
  clearTimeout(this.saveTimer);
1309
1498
  this.saveTimer = null;
@@ -1312,6 +1501,7 @@ export class Editor extends RapidElement {
1312
1501
  clearTimeout(this.activityTimer);
1313
1502
  this.activityTimer = null;
1314
1503
  }
1504
+ this.clearReflowAutoSaveTimer();
1315
1505
  document.removeEventListener('mousemove', this.boundMouseMove);
1316
1506
  document.removeEventListener('mouseup', this.boundMouseUp);
1317
1507
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
@@ -1322,6 +1512,11 @@ export class Editor extends RapidElement {
1322
1512
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1323
1513
  }
1324
1514
 
1515
+ const editor = this.querySelector('#editor');
1516
+ if (editor) {
1517
+ editor.removeEventListener('wheel', this.boundWheel);
1518
+ }
1519
+
1325
1520
  // Clear all flow-specific data from the store so stale data
1326
1521
  // isn't briefly visible when a different flow is opened.
1327
1522
  zustand.getState().clearFlowData();
@@ -1338,6 +1533,11 @@ export class Editor extends RapidElement {
1338
1533
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1339
1534
  }
1340
1535
 
1536
+ const editor = this.querySelector('#editor');
1537
+ if (editor) {
1538
+ editor.addEventListener('wheel', this.boundWheel, { passive: false });
1539
+ }
1540
+
1341
1541
  // Listen for action edit requests from flow nodes
1342
1542
  this.addEventListener(
1343
1543
  CustomEventType.ActionEditRequested,
@@ -1409,8 +1609,7 @@ export class Editor extends RapidElement {
1409
1609
  }
1410
1610
 
1411
1611
  private handleMouseDown(event: MouseEvent): void {
1412
- // ignore right clicks
1413
- if (event.button !== 0) return;
1612
+ if (isRightClick(event)) return;
1414
1613
 
1415
1614
  if (this.isReadOnly()) return;
1416
1615
  this.blurActiveContentEditable();
@@ -1454,8 +1653,7 @@ export class Editor extends RapidElement {
1454
1653
  }
1455
1654
 
1456
1655
  private handleGlobalMouseDown(event: MouseEvent): void {
1457
- // ignore right clicks
1458
- if (event.button !== 0) return;
1656
+ if (isRightClick(event)) return;
1459
1657
 
1460
1658
  // Check if the click is within our canvas
1461
1659
  const canvasRect = this.querySelector('#grid')?.getBoundingClientRect();
@@ -1513,8 +1711,8 @@ export class Editor extends RapidElement {
1513
1711
  // Clear current selection
1514
1712
  this.selectedItems.clear();
1515
1713
 
1516
- const relativeX = event.clientX - canvasRect.left;
1517
- const relativeY = event.clientY - canvasRect.top;
1714
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1715
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1518
1716
 
1519
1717
  this.selectionBox = {
1520
1718
  startX: relativeX,
@@ -1540,6 +1738,154 @@ export class Editor extends RapidElement {
1540
1738
  }
1541
1739
  }
1542
1740
 
1741
+ // --- Zoom ---
1742
+
1743
+ private setZoom(
1744
+ newZoom: number,
1745
+ center?: { clientX: number; clientY: number }
1746
+ ): void {
1747
+ const clamped = Math.max(
1748
+ 0.1,
1749
+ Math.min(1.0, Math.round(newZoom * 100) / 100)
1750
+ );
1751
+ if (clamped === this.zoom) return;
1752
+
1753
+ const editor = this.querySelector('#editor') as HTMLElement;
1754
+ const oldZoom = this.zoom;
1755
+ this.zoom = clamped;
1756
+ this.plumber.zoom = clamped;
1757
+ this.zoomFitted = false;
1758
+
1759
+ if (editor && center) {
1760
+ const editorRect = editor.getBoundingClientRect();
1761
+ const ox = center.clientX - editorRect.left;
1762
+ const oy = center.clientY - editorRect.top;
1763
+ // Canvas point under cursor at old zoom
1764
+ const cx = (editor.scrollLeft + ox) / oldZoom;
1765
+ const cy = (editor.scrollTop + oy) / oldZoom;
1766
+
1767
+ requestAnimationFrame(() => {
1768
+ editor.scrollLeft = cx * clamped - ox;
1769
+ editor.scrollTop = cy * clamped - oy;
1770
+ this.plumber.repaintEverything();
1771
+ });
1772
+ } else {
1773
+ requestAnimationFrame(() => this.plumber.repaintEverything());
1774
+ }
1775
+ }
1776
+
1777
+ private zoomIn(): void {
1778
+ this.setZoom(this.zoom + 0.05);
1779
+ }
1780
+
1781
+ private zoomOut(): void {
1782
+ this.setZoom(this.zoom - 0.05);
1783
+ }
1784
+
1785
+ private zoomToFit(): void {
1786
+ if (!this.definition || this.definition.nodes.length === 0) return;
1787
+
1788
+ const editor = this.querySelector('#editor') as HTMLElement;
1789
+ if (!editor) return;
1790
+
1791
+ // Calculate bounding box of all content in canvas coordinates
1792
+ let minX = Infinity;
1793
+ let minY = Infinity;
1794
+ let maxX = -Infinity;
1795
+ let maxY = -Infinity;
1796
+
1797
+ this.definition.nodes.forEach((node) => {
1798
+ const ui = this.definition._ui?.nodes[node.uuid];
1799
+ if (!ui?.position) return;
1800
+ const el = this.querySelector(`[id="${node.uuid}"]`) as HTMLElement;
1801
+ if (!el) return;
1802
+ const w = el.offsetWidth;
1803
+ const h = el.offsetHeight;
1804
+ minX = Math.min(minX, ui.position.left);
1805
+ minY = Math.min(minY, ui.position.top);
1806
+ maxX = Math.max(maxX, ui.position.left + w);
1807
+ maxY = Math.max(maxY, ui.position.top + h);
1808
+ });
1809
+
1810
+ const stickies = this.definition._ui?.stickies || {};
1811
+ Object.entries(stickies).forEach(([uuid, sticky]) => {
1812
+ if (!sticky.position) return;
1813
+ const el = this.querySelector(
1814
+ `temba-sticky-note[uuid="${uuid}"]`
1815
+ ) as HTMLElement;
1816
+ if (!el) return;
1817
+ const w = el.offsetWidth;
1818
+ const h = el.offsetHeight;
1819
+ minX = Math.min(minX, sticky.position.left);
1820
+ minY = Math.min(minY, sticky.position.top);
1821
+ maxX = Math.max(maxX, sticky.position.left + w);
1822
+ maxY = Math.max(maxY, sticky.position.top + h);
1823
+ });
1824
+
1825
+ if (minX === Infinity) return;
1826
+
1827
+ const contentWidth = maxX - minX;
1828
+ const contentHeight = maxY - minY;
1829
+ const padding = 40;
1830
+
1831
+ const availWidth = editor.clientWidth - padding * 2;
1832
+ const availHeight = editor.clientHeight - padding * 2;
1833
+
1834
+ const scaleX = availWidth / contentWidth;
1835
+ const scaleY = availHeight / contentHeight;
1836
+ let fitZoom = Math.min(scaleX, scaleY, 1.0);
1837
+ fitZoom = Math.max(fitZoom, 0.1);
1838
+ fitZoom = Math.round(fitZoom * 20) / 20; // round to nearest 0.05
1839
+
1840
+ this.zoom = fitZoom;
1841
+ this.plumber.zoom = fitZoom;
1842
+ this.zoomFitted = true;
1843
+
1844
+ // Center of content in canvas coordinates, plus grid/canvas margin offset
1845
+ const centerX = (minX + maxX) / 2 + 40;
1846
+ const centerY = (minY + maxY) / 2 + 40;
1847
+
1848
+ requestAnimationFrame(() => {
1849
+ editor.scrollLeft = centerX * fitZoom - editor.clientWidth / 2;
1850
+ editor.scrollTop = centerY * fitZoom - editor.clientHeight / 2;
1851
+ this.plumber.repaintEverything();
1852
+ });
1853
+ }
1854
+
1855
+ private zoomToFull(): void {
1856
+ this.setZoom(1.0);
1857
+ }
1858
+
1859
+ /** Adjust zoom control right offset and floating tab positions */
1860
+ private updateZoomControlPositioning(): void {
1861
+ requestAnimationFrame(() => {
1862
+ const editor = this.querySelector('#editor') as HTMLElement;
1863
+ const zoomControls = this.querySelector('.zoom-controls') as HTMLElement;
1864
+ if (editor && zoomControls) {
1865
+ // Match right spacing to the top spacing (8px) by accounting for
1866
+ // the scrollbar width
1867
+ const scrollbarWidth = editor.offsetWidth - editor.clientWidth;
1868
+ zoomControls.style.right = `${8 + scrollbarWidth}px`;
1869
+ }
1870
+ if (zoomControls) {
1871
+ const rect = zoomControls.getBoundingClientRect();
1872
+ FloatingTab.START_TOP = rect.bottom + 8;
1873
+ FloatingTab.updateAllPositions();
1874
+ }
1875
+ });
1876
+ }
1877
+
1878
+ private handleWheel(event: WheelEvent): void {
1879
+ if (!event.ctrlKey && !event.metaKey) return;
1880
+ event.preventDefault();
1881
+
1882
+ const delta = event.deltaY > 0 ? -0.05 : 0.05;
1883
+ this.setZoom(this.zoom + delta, {
1884
+ clientX: event.clientX,
1885
+ clientY: event.clientY
1886
+ });
1887
+ }
1888
+
1543
1889
  private showDeleteConfirmation(): void {
1544
1890
  const itemCount = this.selectedItems.size;
1545
1891
  const itemType = itemCount === 1 ? 'item' : 'items';
@@ -1569,6 +1915,158 @@ export class Editor extends RapidElement {
1569
1915
  });
1570
1916
  }
1571
1917
 
1918
+ private performReflow(): void {
1919
+ if (!this.definition || this.definition.nodes.length === 0) return;
1920
+
1921
+ // Save current positions for discard (nodes + stickies)
1922
+ const savedPositions: Record<string, FlowPosition> = {};
1923
+ for (const node of this.definition.nodes) {
1924
+ const ui = this.definition._ui?.nodes[node.uuid];
1925
+ if (ui?.position) {
1926
+ savedPositions[node.uuid] = { ...ui.position };
1927
+ }
1928
+ }
1929
+ const stickies = this.definition._ui?.stickies || {};
1930
+ for (const [uuid, sticky] of Object.entries(stickies)) {
1931
+ if (sticky.position) {
1932
+ savedPositions[uuid] = { ...sticky.position };
1933
+ }
1934
+ }
1935
+ this.savedReflowPositions = savedPositions;
1936
+
1937
+ // Save old node positions before reflow for sticky proximity calculation
1938
+ const oldNodePositions: Record<string, FlowPosition> = {};
1939
+ for (const node of this.definition.nodes) {
1940
+ const ui = this.definition._ui?.nodes[node.uuid];
1941
+ if (ui?.position) {
1942
+ oldNodePositions[node.uuid] = { ...ui.position };
1943
+ }
1944
+ }
1945
+
1946
+ // Identify start node (first in sorted array)
1947
+ const startNodeUuid = this.definition.nodes[0].uuid;
1948
+
1949
+ // Gather node sizes from DOM
1950
+ const nodeSizes = new Map<string, { width: number; height: number }>();
1951
+ const getNodeSize = (uuid: string): { width: number; height: number } => {
1952
+ const element = this.querySelector(`[id="${uuid}"]`) as HTMLElement;
1953
+ if (element) {
1954
+ const size = {
1955
+ width: element.offsetWidth,
1956
+ height: element.offsetHeight
1957
+ };
1958
+ nodeSizes.set(uuid, size);
1959
+ return size;
1960
+ }
1961
+ const fallback = { width: 200, height: 100 };
1962
+ nodeSizes.set(uuid, fallback);
1963
+ return fallback;
1964
+ };
1965
+
1966
+ // Compute new layout
1967
+ const newPositions = calculateLayeredLayout(
1968
+ this.definition.nodes,
1969
+ this.definition._ui.nodes,
1970
+ startNodeUuid,
1971
+ getNodeSize
1972
+ );
1973
+
1974
+ // Place sticky notes next to their closest nodes
1975
+ if (Object.keys(stickies).length > 0) {
1976
+ const stickySizes = new Map<string, { width: number; height: number }>();
1977
+ for (const uuid of Object.keys(stickies)) {
1978
+ const el = this.querySelector(
1979
+ `temba-sticky-note[uuid="${uuid}"]`
1980
+ ) as HTMLElement;
1981
+ if (el) {
1982
+ stickySizes.set(uuid, {
1983
+ width: el.offsetWidth,
1984
+ height: el.offsetHeight
1985
+ });
1986
+ } else {
1987
+ stickySizes.set(uuid, { width: 182, height: 100 });
1988
+ }
1989
+ }
1990
+
1991
+ const stickyPositions = placeStickyNotes(
1992
+ stickies,
1993
+ oldNodePositions,
1994
+ newPositions,
1995
+ nodeSizes,
1996
+ stickySizes,
1997
+ startNodeUuid
1998
+ );
1999
+
2000
+ // Merge sticky positions into newPositions
2001
+ Object.assign(newPositions, stickyPositions);
2002
+ }
2003
+
2004
+ // Cancel any in-flight save timer so it doesn't persist the reflowed
2005
+ // layout before the user has a chance to Save or Discard.
2006
+ if (this.saveTimer !== null) {
2007
+ clearTimeout(this.saveTimer);
2008
+ this.saveTimer = null;
2009
+ }
2010
+
2011
+ // Suppress the auto-save from this updateCanvasPositions call
2012
+ this.reflowPending = true;
2013
+ this.reflowUnsaved = true;
2014
+
2015
+ // Apply new positions
2016
+ getStore().getState().updateCanvasPositions(newPositions);
2017
+
2018
+ // Update canvas size and repaint connections
2019
+ this.updateCanvasSize();
2020
+ requestAnimationFrame(() => {
2021
+ this.plumber.repaintEverything();
2022
+ });
2023
+
2024
+ // Scroll to top-left so the start node is visible
2025
+ const editor = this.querySelector('#editor') as HTMLElement;
2026
+ if (editor) {
2027
+ editor.scrollTo({ left: 0, top: 0, behavior: 'smooth' });
2028
+ }
2029
+
2030
+ // Start auto-save countdown (duration shared with CSS animation)
2031
+ this.clearReflowAutoSaveTimer();
2032
+ this.reflowAutoSaveTimer = setTimeout(() => {
2033
+ this.reflowAutoSaveTimer = null;
2034
+ if (this.reflowUnsaved) {
2035
+ this.reflowUnsaved = false;
2036
+ this.savedReflowPositions = null;
2037
+ this.saveChanges();
2038
+ }
2039
+ }, REFLOW_AUTO_SAVE_DELAY);
2040
+ }
2041
+
2042
+ private handleReflowDiscard(): void {
2043
+ this.reflowUnsaved = false;
2044
+ this.clearReflowAutoSaveTimer();
2045
+
2046
+ if (this.savedReflowPositions) {
2047
+ // Cancel any pending save timer before reverting
2048
+ if (this.saveTimer !== null) {
2049
+ clearTimeout(this.saveTimer);
2050
+ this.saveTimer = null;
2051
+ }
2052
+
2053
+ // Suppress the auto-save from reverting positions
2054
+ this.reflowPending = true;
2055
+ getStore().getState().updateCanvasPositions(this.savedReflowPositions);
2056
+ this.savedReflowPositions = null;
2057
+
2058
+ // Clear dirty state since we reverted to the saved version
2059
+ setTimeout(() => {
2060
+ getStore().getState().setDirtyDate(null);
2061
+ this.isSaving = false;
2062
+ }, 0);
2063
+
2064
+ requestAnimationFrame(() => {
2065
+ this.plumber.repaintEverything();
2066
+ });
2067
+ }
2068
+ }
2069
+
1572
2070
  private deleteNodes(uuids: string[]): void {
1573
2071
  // Remove nodes from the definition - CanvasNode will handle plumber cleanup
1574
2072
  if (uuids.length > 0) {
@@ -1598,8 +2096,8 @@ export class Editor extends RapidElement {
1598
2096
  const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
1599
2097
  if (!canvasRect) return;
1600
2098
 
1601
- const relativeX = event.clientX - canvasRect.left;
1602
- const relativeY = event.clientY - canvasRect.top;
2099
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
2100
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1603
2101
 
1604
2102
  this.selectionBox = {
1605
2103
  ...this.selectionBox,
@@ -1626,19 +2124,20 @@ export class Editor extends RapidElement {
1626
2124
 
1627
2125
  // Check nodes
1628
2126
  this.definition?.nodes.forEach((node) => {
1629
- const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
2127
+ const nodeElement = this.querySelector(
2128
+ `[id="${node.uuid}"]`
2129
+ ) as HTMLElement;
1630
2130
  if (nodeElement) {
1631
2131
  const position = this.definition._ui?.nodes[node.uuid]?.position;
1632
2132
  if (position) {
1633
- const rect = nodeElement.getBoundingClientRect();
1634
2133
  const canvasRect =
1635
2134
  this.querySelector('#canvas')?.getBoundingClientRect();
1636
2135
 
1637
2136
  if (canvasRect) {
1638
2137
  const nodeLeft = position.left;
1639
2138
  const nodeTop = position.top;
1640
- const nodeRight = nodeLeft + rect.width;
1641
- const nodeBottom = nodeTop + rect.height;
2139
+ const nodeRight = nodeLeft + nodeElement.offsetWidth;
2140
+ const nodeBottom = nodeTop + nodeElement.offsetHeight;
1642
2141
 
1643
2142
  // Check if selection box intersects with node
1644
2143
  if (
@@ -1913,8 +2412,8 @@ export class Editor extends RapidElement {
1913
2412
  const canvas = this.querySelector('#canvas');
1914
2413
  if (canvas) {
1915
2414
  const canvasRect = canvas.getBoundingClientRect();
1916
- const relativeX = event.clientX - canvasRect.left;
1917
- const relativeY = event.clientY - canvasRect.top;
2415
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
2416
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1918
2417
 
1919
2418
  const placeholderWidth = 200;
1920
2419
  const placeholderHeight = 64;
@@ -1956,46 +2455,147 @@ export class Editor extends RapidElement {
1956
2455
  // Handle item dragging
1957
2456
  if (!this.isMouseDown || !this.currentDragItem) return;
1958
2457
 
1959
- const deltaX = event.clientX - this.dragStartPos.x;
1960
- const deltaY = event.clientY - this.dragStartPos.y;
2458
+ this.lastMouseEvent = event;
2459
+
2460
+ const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
2461
+ const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
1961
2462
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1962
2463
 
1963
2464
  // Only start dragging if we've moved beyond the threshold
1964
2465
  if (!this.isDragging && distance > DRAG_THRESHOLD) {
1965
2466
  this.isDragging = true;
2467
+ this.startAutoScroll();
1966
2468
  }
1967
2469
 
1968
2470
  // If we're actually dragging, update positions
1969
2471
  if (this.isDragging) {
1970
- // Determine what items to move
1971
- const itemsToMove =
1972
- this.selectedItems.has(this.currentDragItem.uuid) &&
1973
- this.selectedItems.size > 1
1974
- ? Array.from(this.selectedItems)
1975
- : [this.currentDragItem.uuid];
2472
+ this.updateDragPositions();
2473
+ }
2474
+ }
1976
2475
 
1977
- itemsToMove.forEach((uuid) => {
1978
- const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
1979
- if (element) {
1980
- const type =
1981
- element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1982
- const position = this.getPosition(uuid, type);
1983
-
1984
- if (position) {
1985
- const newLeft = position.left + deltaX;
1986
- const newTop = position.top + deltaY;
1987
-
1988
- // Update the visual position during drag
1989
- element.style.left = `${newLeft}px`;
1990
- element.style.top = `${newTop}px`;
1991
-
1992
- // Add dragging class to ensure highest z-index
1993
- element.classList.add('dragging');
1994
- }
2476
+ private updateDragPositions(): void {
2477
+ if (!this.currentDragItem || !this.lastMouseEvent) return;
2478
+
2479
+ // Convert screen + scroll delta to canvas delta
2480
+ const deltaX =
2481
+ (this.lastMouseEvent.clientX -
2482
+ this.dragStartPos.x +
2483
+ this.autoScrollDeltaX) /
2484
+ this.zoom;
2485
+ const deltaY =
2486
+ (this.lastMouseEvent.clientY -
2487
+ this.dragStartPos.y +
2488
+ this.autoScrollDeltaY) /
2489
+ this.zoom;
2490
+
2491
+ const itemsToMove =
2492
+ this.selectedItems.has(this.currentDragItem.uuid) &&
2493
+ this.selectedItems.size > 1
2494
+ ? Array.from(this.selectedItems)
2495
+ : [this.currentDragItem.uuid];
2496
+
2497
+ itemsToMove.forEach((uuid) => {
2498
+ const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
2499
+ if (element) {
2500
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
2501
+ const position = this.getPosition(uuid, type);
2502
+
2503
+ if (position) {
2504
+ element.style.left = `${position.left + deltaX}px`;
2505
+ element.style.top = `${position.top + deltaY}px`;
2506
+ element.classList.add('dragging');
1995
2507
  }
1996
- });
2508
+ }
2509
+ });
2510
+
2511
+ this.plumber.revalidate(itemsToMove);
2512
+ }
1997
2513
 
1998
- this.plumber.revalidate(itemsToMove);
2514
+ private startAutoScroll(): void {
2515
+ if (this.autoScrollAnimationId !== null) return;
2516
+
2517
+ const editor = this.querySelector('#editor') as HTMLElement;
2518
+ if (!editor) return;
2519
+
2520
+ const tick = () => {
2521
+ if (!this.isDragging || !this.lastMouseEvent) {
2522
+ this.autoScrollAnimationId = null;
2523
+ return;
2524
+ }
2525
+
2526
+ const editorRect = editor.getBoundingClientRect();
2527
+ const mouseX = this.lastMouseEvent.clientX;
2528
+ const mouseY = this.lastMouseEvent.clientY;
2529
+
2530
+ let scrollDx = 0;
2531
+ let scrollDy = 0;
2532
+
2533
+ // Left edge
2534
+ const distFromLeft = mouseX - editorRect.left;
2535
+ if (distFromLeft >= 0 && distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
2536
+ const ratio = 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE;
2537
+ scrollDx = -(ratio * AUTO_SCROLL_MAX_SPEED);
2538
+ }
2539
+
2540
+ // Right edge
2541
+ const distFromRight = editorRect.right - mouseX;
2542
+ if (distFromRight >= 0 && distFromRight < AUTO_SCROLL_EDGE_ZONE) {
2543
+ const ratio = 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE;
2544
+ scrollDx = ratio * AUTO_SCROLL_MAX_SPEED;
2545
+ }
2546
+
2547
+ // Top edge
2548
+ const distFromTop = mouseY - editorRect.top;
2549
+ if (distFromTop >= 0 && distFromTop < AUTO_SCROLL_EDGE_ZONE) {
2550
+ const ratio = 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE;
2551
+ scrollDy = -(ratio * AUTO_SCROLL_MAX_SPEED);
2552
+ }
2553
+
2554
+ // Bottom edge
2555
+ const distFromBottom = editorRect.bottom - mouseY;
2556
+ if (distFromBottom >= 0 && distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
2557
+ const ratio = 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE;
2558
+ scrollDy = ratio * AUTO_SCROLL_MAX_SPEED;
2559
+ }
2560
+
2561
+ if (scrollDx !== 0 || scrollDy !== 0) {
2562
+ const beforeScrollLeft = editor.scrollLeft;
2563
+ const beforeScrollTop = editor.scrollTop;
2564
+
2565
+ // Expand canvas if scrolling toward bottom/right edges
2566
+ // Convert from scroll space to canvas space for expandCanvas
2567
+ if (scrollDx > 0 || scrollDy > 0) {
2568
+ const neededWidth =
2569
+ (editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
2570
+ const neededHeight =
2571
+ (editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
2572
+ getStore().getState().expandCanvas(neededWidth, neededHeight);
2573
+ }
2574
+
2575
+ editor.scrollLeft += scrollDx;
2576
+ editor.scrollTop += scrollDy;
2577
+
2578
+ // Track actual scroll delta (browser clamps at boundaries)
2579
+ const actualDx = editor.scrollLeft - beforeScrollLeft;
2580
+ const actualDy = editor.scrollTop - beforeScrollTop;
2581
+ this.autoScrollDeltaX += actualDx;
2582
+ this.autoScrollDeltaY += actualDy;
2583
+
2584
+ if (actualDx !== 0 || actualDy !== 0) {
2585
+ this.updateDragPositions();
2586
+ }
2587
+ }
2588
+
2589
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
2590
+ };
2591
+
2592
+ this.autoScrollAnimationId = requestAnimationFrame(tick);
2593
+ }
2594
+
2595
+ private stopAutoScroll(): void {
2596
+ if (this.autoScrollAnimationId !== null) {
2597
+ cancelAnimationFrame(this.autoScrollAnimationId);
2598
+ this.autoScrollAnimationId = null;
1999
2599
  }
2000
2600
  }
2001
2601
 
@@ -2018,10 +2618,17 @@ export class Editor extends RapidElement {
2018
2618
  // Handle item drag completion
2019
2619
  if (!this.isMouseDown || !this.currentDragItem) return;
2020
2620
 
2621
+ this.stopAutoScroll();
2622
+
2021
2623
  // If we were actually dragging, handle the drag end
2022
2624
  if (this.isDragging) {
2023
- const deltaX = event.clientX - this.dragStartPos.x;
2024
- const deltaY = event.clientY - this.dragStartPos.y;
2625
+ // Convert screen + scroll delta to canvas delta
2626
+ const deltaX =
2627
+ (event.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
2628
+ this.zoom;
2629
+ const deltaY =
2630
+ (event.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
2631
+ this.zoom;
2025
2632
 
2026
2633
  // Determine what items were moved
2027
2634
  const itemsToMove =
@@ -2090,6 +2697,9 @@ export class Editor extends RapidElement {
2090
2697
  this.isMouseDown = false;
2091
2698
  this.currentDragItem = null;
2092
2699
  this.canvasMouseDown = false;
2700
+ this.autoScrollDeltaX = 0;
2701
+ this.autoScrollDeltaY = 0;
2702
+ this.lastMouseEvent = null;
2093
2703
  }
2094
2704
 
2095
2705
  private updateCanvasSize(): void {
@@ -2106,11 +2716,19 @@ export class Editor extends RapidElement {
2106
2716
  this.definition.nodes.forEach((node) => {
2107
2717
  const ui = this.definition._ui.nodes[node.uuid];
2108
2718
  if (ui && ui.position) {
2109
- const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
2719
+ const nodeElement = this.querySelector(
2720
+ `[id="${node.uuid}"]`
2721
+ ) as HTMLElement;
2110
2722
  if (nodeElement) {
2111
- const rect = nodeElement.getBoundingClientRect();
2112
- maxWidth = Math.max(maxWidth, ui.position.left + rect.width);
2113
- maxHeight = Math.max(maxHeight, ui.position.top + rect.height);
2723
+ // Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
2724
+ maxWidth = Math.max(
2725
+ maxWidth,
2726
+ ui.position.left + nodeElement.offsetWidth
2727
+ );
2728
+ maxHeight = Math.max(
2729
+ maxHeight,
2730
+ ui.position.top + nodeElement.offsetHeight
2731
+ );
2114
2732
  }
2115
2733
  }
2116
2734
  });
@@ -2166,8 +2784,8 @@ export class Editor extends RapidElement {
2166
2784
  }
2167
2785
 
2168
2786
  const canvasRect = canvas.getBoundingClientRect();
2169
- const relativeX = event.clientX - canvasRect.left - 10;
2170
- const relativeY = event.clientY - canvasRect.top - 10;
2787
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
2788
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
2171
2789
 
2172
2790
  // Snap position to grid
2173
2791
  const snappedLeft = snapToGrid(relativeX);
@@ -2176,10 +2794,17 @@ export class Editor extends RapidElement {
2176
2794
  // Show the canvas menu at the mouse position (use viewport coordinates)
2177
2795
  const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
2178
2796
  if (canvasMenu) {
2179
- canvasMenu.show(event.clientX, event.clientY, {
2180
- x: snappedLeft,
2181
- y: snappedTop
2182
- });
2797
+ const hasNodes = this.definition && this.definition.nodes.length > 0;
2798
+ canvasMenu.show(
2799
+ event.clientX,
2800
+ event.clientY,
2801
+ {
2802
+ x: snappedLeft,
2803
+ y: snappedTop
2804
+ },
2805
+ true,
2806
+ hasNodes
2807
+ );
2183
2808
  }
2184
2809
  }
2185
2810
 
@@ -2209,6 +2834,11 @@ export class Editor extends RapidElement {
2209
2834
  const selection = event.detail as CanvasMenuSelection;
2210
2835
  const store = getStore();
2211
2836
 
2837
+ if (selection.action === 'reflow') {
2838
+ this.performReflow();
2839
+ return;
2840
+ }
2841
+
2212
2842
  if (selection.action === 'sticky') {
2213
2843
  // Create new sticky note
2214
2844
  store.getState().createStickyNote({
@@ -2617,8 +3247,9 @@ export class Editor extends RapidElement {
2617
3247
  }
2618
3248
 
2619
3249
  // Check for collisions and reflow in case node size changed
3250
+ const nodeUuid = updatedNode.uuid;
2620
3251
  requestAnimationFrame(() => {
2621
- this.checkCollisionsAndReflow([this.editingNode.uuid]);
3252
+ this.checkCollisionsAndReflow([nodeUuid]);
2622
3253
  });
2623
3254
  }
2624
3255
  }
@@ -2661,11 +3292,9 @@ export class Editor extends RapidElement {
2661
3292
 
2662
3293
  const canvasRect = canvas.getBoundingClientRect();
2663
3294
 
2664
- // calculate position relative to canvas
2665
- // canvasRect gives us the canvas position in the viewport, which already accounts for scroll
2666
- // so we just need mouseX/Y - canvasRect.left/top to get position within canvas
2667
- const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
2668
- const top = mouseY - canvasRect.top - DROP_PREVIEW_OFFSET_Y;
3295
+ // Convert viewport coordinates to canvas coordinates, accounting for zoom
3296
+ const left = (mouseX - canvasRect.left) / this.zoom - DROP_PREVIEW_OFFSET_X;
3297
+ const top = (mouseY - canvasRect.top) / this.zoom - DROP_PREVIEW_OFFSET_Y;
2669
3298
 
2670
3299
  // Apply grid snapping only if requested (for final drop position)
2671
3300
  if (applyGridSnapping) {
@@ -3986,14 +4615,15 @@ export class Editor extends RapidElement {
3986
4615
  const editorCenterX = editorRect.width / 2;
3987
4616
  const editorCenterY = editorRect.height / 2;
3988
4617
 
3989
- // Get node position relative to the editor's scroll container
3990
- const nodeRect = nodeElement.getBoundingClientRect();
3991
- const nodeCenterX = nodeElement.offsetLeft + nodeRect.width / 2;
3992
- const nodeCenterY = nodeElement.offsetTop + nodeRect.height / 2;
4618
+ // Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
4619
+ const nodeCenterX = nodeElement.offsetLeft + nodeElement.offsetWidth / 2;
4620
+ const nodeCenterY = nodeElement.offsetTop + nodeElement.offsetHeight / 2;
3993
4621
 
3994
- // Calculate the scroll position needed to center the node
3995
- const targetScrollX = nodeCenterX - editorCenterX;
3996
- const targetScrollY = nodeCenterY - editorCenterY;
4622
+ // Calculate the scroll position needed to center the node.
4623
+ // Multiply by zoom because scroll operates in visual (transformed) space
4624
+ // while offsetLeft/offsetTop are in layout space.
4625
+ const targetScrollX = nodeCenterX * this.zoom - editorCenterX;
4626
+ const targetScrollY = nodeCenterY * this.zoom - editorCenterY;
3997
4627
 
3998
4628
  // Smooth scroll the editor container to the target position
3999
4629
  editor.scrollTo({
@@ -4043,8 +4673,9 @@ export class Editor extends RapidElement {
4043
4673
  <div
4044
4674
  id="grid"
4045
4675
  class="${this.viewingRevision ? 'viewing-revision' : ''}"
4046
- style="min-width:100%;width:${this.canvasSize
4047
- .width}px; height:${this.canvasSize.height}px"
4676
+ style="min-width:${100 / this.zoom}%;min-height:${100 /
4677
+ this.zoom}%;width:${this.canvasSize.width}px; height:${this
4678
+ .canvasSize.height}px;transform:scale(${this.zoom})"
4048
4679
  >
4049
4680
  <div
4050
4681
  id="canvas"
@@ -4127,6 +4758,55 @@ export class Editor extends RapidElement {
4127
4758
  <div class="save-indicator ${this.isSaving ? 'visible' : ''}">
4128
4759
  <temba-loading units="3" size="8"></temba-loading>
4129
4760
  </div>
4761
+ <div class="zoom-controls">
4762
+ <button
4763
+ @click=${this.zoomToFit}
4764
+ ?disabled=${this.zoomFitted}
4765
+ title="Zoom to fit"
4766
+ >
4767
+ <temba-icon name=${Icon.zoom_fit} size="1"></temba-icon>
4768
+ </button>
4769
+ <div class="zoom-divider"></div>
4770
+ <button
4771
+ @click=${this.zoomOut}
4772
+ ?disabled=${this.zoom <= 0.1}
4773
+ title="Zoom out"
4774
+ >
4775
+
4776
+ </button>
4777
+ <span class="zoom-level">${Math.round(this.zoom * 100)}%</span>
4778
+ <button
4779
+ @click=${this.zoomIn}
4780
+ ?disabled=${this.zoom >= 1.0}
4781
+ title="Zoom in"
4782
+ >
4783
+ +
4784
+ </button>
4785
+ <div class="zoom-divider"></div>
4786
+ <button
4787
+ @click=${this.zoomToFull}
4788
+ ?disabled=${this.zoom >= 1.0}
4789
+ title="Zoom to 100%"
4790
+ >
4791
+ <temba-icon name=${Icon.zoom_in} size="1"></temba-icon>
4792
+ </button>
4793
+ </div>
4794
+ ${this.reflowUnsaved
4795
+ ? html`<div class="reflow-card">
4796
+ <div class="reflow-top">
4797
+ <span class="reflow-label">Unsaved layout changes</span>
4798
+ <button
4799
+ class="reflow-discard"
4800
+ @click=${this.handleReflowDiscard}
4801
+ >
4802
+ Discard
4803
+ </button>
4804
+ </div>
4805
+ <div class="reflow-meter">
4806
+ <div class="reflow-meter-fill"></div>
4807
+ </div>
4808
+ </div>`
4809
+ : ''}
4130
4810
  </div>
4131
4811
 
4132
4812
  ${this.editingNode || this.editingAction