@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
@@ -26,14 +26,18 @@ import {
26
26
  getClasses,
27
27
  WebResponse
28
28
  } from '../utils';
29
+ import { TEMBA_COMPONENTS_VERSION } from '../version';
29
30
  import {
30
31
  formatIssueMessage,
31
32
  getNodeBounds,
32
33
  calculateReflowPositions,
34
+ isRightClick,
33
35
  NodeBounds,
34
36
  snapToGrid
35
37
  } from './utils';
36
38
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
39
+ import { calculateLayeredLayout, placeStickyNotes } from './reflow';
40
+ import { FloatingTab } from '../display/FloatingTab';
37
41
 
38
42
  interface Revision {
39
43
  id: number;
@@ -64,6 +68,7 @@ import { Dialog } from '../layout/Dialog';
64
68
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
65
69
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
66
70
  import { FloatingWindow } from '../layout/FloatingWindow';
71
+ import { Icon } from '../Icons';
67
72
 
68
73
  export function findNodeForExit(
69
74
  definition: FlowDefinition,
@@ -95,6 +100,8 @@ export interface SelectionBox {
95
100
  }
96
101
 
97
102
  const DRAG_THRESHOLD = 5;
103
+ const AUTO_SCROLL_EDGE_ZONE = 100;
104
+ const AUTO_SCROLL_MAX_SPEED = 15;
98
105
 
99
106
  type TranslationType = 'property' | 'category';
100
107
 
@@ -125,6 +132,10 @@ interface LocalizationUpdate {
125
132
 
126
133
  const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
127
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
+
128
139
  // Offset for positioning dropped action node relative to mouse cursor
129
140
  // Keep small to make drop location close to cursor position
130
141
  const DROP_PREVIEW_OFFSET_X = 20;
@@ -199,6 +210,12 @@ export class Editor extends RapidElement {
199
210
  private currentDragItem: DraggableItem | null = null;
200
211
  private startPos = { left: 0, top: 0 };
201
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
+
202
219
  // Selection state
203
220
  @state()
204
221
  private selectedItems: Set<string> = new Set();
@@ -272,6 +289,28 @@ export class Editor extends RapidElement {
272
289
  @state()
273
290
  private saveError: string | null = null;
274
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
+
275
314
  private preRevertState: {
276
315
  definition: FlowDefinition;
277
316
  dirtyDate: Date | null;
@@ -370,6 +409,7 @@ export class Editor extends RapidElement {
370
409
  private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
371
410
  private boundKeyDown = this.handleKeyDown.bind(this);
372
411
  private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
412
+ private boundWheel = this.handleWheel.bind(this);
373
413
 
374
414
  static get styles() {
375
415
  return css`
@@ -403,6 +443,7 @@ export class Editor extends RapidElement {
403
443
  width: 100%;
404
444
  display: flex;
405
445
  padding-top: 20px;
446
+ transform-origin: 0 0;
406
447
  }
407
448
 
408
449
  #canvas {
@@ -912,7 +953,7 @@ export class Editor extends RapidElement {
912
953
  .save-indicator {
913
954
  position: absolute;
914
955
  top: 8px;
915
- right: 16px;
956
+ right: 240px;
916
957
  padding: 6px 10px;
917
958
  z-index: 10000;
918
959
  pointer-events: none;
@@ -923,6 +964,135 @@ export class Editor extends RapidElement {
923
964
  .save-indicator.visible {
924
965
  opacity: 1;
925
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
+ }
926
1096
  `;
927
1097
  }
928
1098
 
@@ -936,6 +1106,7 @@ export class Editor extends RapidElement {
936
1106
  super.firstUpdated(changes);
937
1107
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
938
1108
  this.setupGlobalEventListeners();
1109
+ this.updateZoomControlPositioning();
939
1110
  if (changes.has('flow')) {
940
1111
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
941
1112
  this.fetchRevisions();
@@ -1001,10 +1172,10 @@ export class Editor extends RapidElement {
1001
1172
  const canvas = this.querySelector('#canvas');
1002
1173
  if (canvas) {
1003
1174
  const canvasRect = canvas.getBoundingClientRect();
1004
- const menuX = canvasRect.left + snappedPosition.left - 40;
1175
+ const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
1005
1176
  const menuY = isDragUp
1006
- ? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
1007
- : 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
1008
1179
 
1009
1180
  const canvasMenu = this.querySelector(
1010
1181
  'temba-canvas-menu'
@@ -1103,8 +1274,20 @@ export class Editor extends RapidElement {
1103
1274
 
1104
1275
  if (changes.has('dirtyDate')) {
1105
1276
  if (this.dirtyDate) {
1106
- this.isSaving = true;
1107
- 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
+ }
1108
1291
  }
1109
1292
  }
1110
1293
 
@@ -1144,6 +1327,12 @@ export class Editor extends RapidElement {
1144
1327
  }
1145
1328
 
1146
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
+
1147
1336
  const now = new Date();
1148
1337
  const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
1149
1338
 
@@ -1156,8 +1345,20 @@ export class Editor extends RapidElement {
1156
1345
  }, SAVE_QUIET_TIME);
1157
1346
  }
1158
1347
 
1348
+ private definitionForSave(definition: FlowDefinition): FlowDefinition {
1349
+ return {
1350
+ ...definition,
1351
+ _ui: {
1352
+ ...definition._ui,
1353
+ editor: TEMBA_COMPONENTS_VERSION
1354
+ }
1355
+ };
1356
+ }
1357
+
1159
1358
  private saveChanges(definitionOverride?: FlowDefinition): Promise<void> {
1160
- const definition = definitionOverride || this.definition;
1359
+ const definition = this.definitionForSave(
1360
+ definitionOverride || this.definition
1361
+ );
1161
1362
  this.isSaving = true;
1162
1363
 
1163
1364
  return getStore()
@@ -1291,6 +1492,7 @@ export class Editor extends RapidElement {
1291
1492
 
1292
1493
  disconnectedCallback(): void {
1293
1494
  super.disconnectedCallback();
1495
+ this.stopAutoScroll();
1294
1496
  if (this.saveTimer !== null) {
1295
1497
  clearTimeout(this.saveTimer);
1296
1498
  this.saveTimer = null;
@@ -1299,6 +1501,7 @@ export class Editor extends RapidElement {
1299
1501
  clearTimeout(this.activityTimer);
1300
1502
  this.activityTimer = null;
1301
1503
  }
1504
+ this.clearReflowAutoSaveTimer();
1302
1505
  document.removeEventListener('mousemove', this.boundMouseMove);
1303
1506
  document.removeEventListener('mouseup', this.boundMouseUp);
1304
1507
  document.removeEventListener('mousedown', this.boundGlobalMouseDown);
@@ -1309,6 +1512,11 @@ export class Editor extends RapidElement {
1309
1512
  canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
1310
1513
  }
1311
1514
 
1515
+ const editor = this.querySelector('#editor');
1516
+ if (editor) {
1517
+ editor.removeEventListener('wheel', this.boundWheel);
1518
+ }
1519
+
1312
1520
  // Clear all flow-specific data from the store so stale data
1313
1521
  // isn't briefly visible when a different flow is opened.
1314
1522
  zustand.getState().clearFlowData();
@@ -1325,6 +1533,11 @@ export class Editor extends RapidElement {
1325
1533
  canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
1326
1534
  }
1327
1535
 
1536
+ const editor = this.querySelector('#editor');
1537
+ if (editor) {
1538
+ editor.addEventListener('wheel', this.boundWheel, { passive: false });
1539
+ }
1540
+
1328
1541
  // Listen for action edit requests from flow nodes
1329
1542
  this.addEventListener(
1330
1543
  CustomEventType.ActionEditRequested,
@@ -1396,8 +1609,7 @@ export class Editor extends RapidElement {
1396
1609
  }
1397
1610
 
1398
1611
  private handleMouseDown(event: MouseEvent): void {
1399
- // ignore right clicks
1400
- if (event.button !== 0) return;
1612
+ if (isRightClick(event)) return;
1401
1613
 
1402
1614
  if (this.isReadOnly()) return;
1403
1615
  this.blurActiveContentEditable();
@@ -1441,8 +1653,7 @@ export class Editor extends RapidElement {
1441
1653
  }
1442
1654
 
1443
1655
  private handleGlobalMouseDown(event: MouseEvent): void {
1444
- // ignore right clicks
1445
- if (event.button !== 0) return;
1656
+ if (isRightClick(event)) return;
1446
1657
 
1447
1658
  // Check if the click is within our canvas
1448
1659
  const canvasRect = this.querySelector('#grid')?.getBoundingClientRect();
@@ -1500,8 +1711,8 @@ export class Editor extends RapidElement {
1500
1711
  // Clear current selection
1501
1712
  this.selectedItems.clear();
1502
1713
 
1503
- const relativeX = event.clientX - canvasRect.left;
1504
- const relativeY = event.clientY - canvasRect.top;
1714
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
1715
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1505
1716
 
1506
1717
  this.selectionBox = {
1507
1718
  startX: relativeX,
@@ -1527,6 +1738,154 @@ export class Editor extends RapidElement {
1527
1738
  }
1528
1739
  }
1529
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
+
1530
1889
  private showDeleteConfirmation(): void {
1531
1890
  const itemCount = this.selectedItems.size;
1532
1891
  const itemType = itemCount === 1 ? 'item' : 'items';
@@ -1556,6 +1915,158 @@ export class Editor extends RapidElement {
1556
1915
  });
1557
1916
  }
1558
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
+
1559
2070
  private deleteNodes(uuids: string[]): void {
1560
2071
  // Remove nodes from the definition - CanvasNode will handle plumber cleanup
1561
2072
  if (uuids.length > 0) {
@@ -1585,8 +2096,8 @@ export class Editor extends RapidElement {
1585
2096
  const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
1586
2097
  if (!canvasRect) return;
1587
2098
 
1588
- const relativeX = event.clientX - canvasRect.left;
1589
- const relativeY = event.clientY - canvasRect.top;
2099
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
2100
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1590
2101
 
1591
2102
  this.selectionBox = {
1592
2103
  ...this.selectionBox,
@@ -1613,19 +2124,20 @@ export class Editor extends RapidElement {
1613
2124
 
1614
2125
  // Check nodes
1615
2126
  this.definition?.nodes.forEach((node) => {
1616
- const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
2127
+ const nodeElement = this.querySelector(
2128
+ `[id="${node.uuid}"]`
2129
+ ) as HTMLElement;
1617
2130
  if (nodeElement) {
1618
2131
  const position = this.definition._ui?.nodes[node.uuid]?.position;
1619
2132
  if (position) {
1620
- const rect = nodeElement.getBoundingClientRect();
1621
2133
  const canvasRect =
1622
2134
  this.querySelector('#canvas')?.getBoundingClientRect();
1623
2135
 
1624
2136
  if (canvasRect) {
1625
2137
  const nodeLeft = position.left;
1626
2138
  const nodeTop = position.top;
1627
- const nodeRight = nodeLeft + rect.width;
1628
- const nodeBottom = nodeTop + rect.height;
2139
+ const nodeRight = nodeLeft + nodeElement.offsetWidth;
2140
+ const nodeBottom = nodeTop + nodeElement.offsetHeight;
1629
2141
 
1630
2142
  // Check if selection box intersects with node
1631
2143
  if (
@@ -1900,8 +2412,8 @@ export class Editor extends RapidElement {
1900
2412
  const canvas = this.querySelector('#canvas');
1901
2413
  if (canvas) {
1902
2414
  const canvasRect = canvas.getBoundingClientRect();
1903
- const relativeX = event.clientX - canvasRect.left;
1904
- const relativeY = event.clientY - canvasRect.top;
2415
+ const relativeX = (event.clientX - canvasRect.left) / this.zoom;
2416
+ const relativeY = (event.clientY - canvasRect.top) / this.zoom;
1905
2417
 
1906
2418
  const placeholderWidth = 200;
1907
2419
  const placeholderHeight = 64;
@@ -1943,46 +2455,147 @@ export class Editor extends RapidElement {
1943
2455
  // Handle item dragging
1944
2456
  if (!this.isMouseDown || !this.currentDragItem) return;
1945
2457
 
1946
- const deltaX = event.clientX - this.dragStartPos.x;
1947
- 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;
1948
2462
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1949
2463
 
1950
2464
  // Only start dragging if we've moved beyond the threshold
1951
2465
  if (!this.isDragging && distance > DRAG_THRESHOLD) {
1952
2466
  this.isDragging = true;
2467
+ this.startAutoScroll();
1953
2468
  }
1954
2469
 
1955
2470
  // If we're actually dragging, update positions
1956
2471
  if (this.isDragging) {
1957
- // Determine what items to move
1958
- const itemsToMove =
1959
- this.selectedItems.has(this.currentDragItem.uuid) &&
1960
- this.selectedItems.size > 1
1961
- ? Array.from(this.selectedItems)
1962
- : [this.currentDragItem.uuid];
2472
+ this.updateDragPositions();
2473
+ }
2474
+ }
1963
2475
 
1964
- itemsToMove.forEach((uuid) => {
1965
- const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
1966
- if (element) {
1967
- const type =
1968
- element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
1969
- const position = this.getPosition(uuid, type);
1970
-
1971
- if (position) {
1972
- const newLeft = position.left + deltaX;
1973
- const newTop = position.top + deltaY;
1974
-
1975
- // Update the visual position during drag
1976
- element.style.left = `${newLeft}px`;
1977
- element.style.top = `${newTop}px`;
1978
-
1979
- // Add dragging class to ensure highest z-index
1980
- element.classList.add('dragging');
1981
- }
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');
1982
2507
  }
1983
- });
2508
+ }
2509
+ });
2510
+
2511
+ this.plumber.revalidate(itemsToMove);
2512
+ }
2513
+
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
+ }
1984
2546
 
1985
- this.plumber.revalidate(itemsToMove);
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;
1986
2599
  }
1987
2600
  }
1988
2601
 
@@ -2005,10 +2618,17 @@ export class Editor extends RapidElement {
2005
2618
  // Handle item drag completion
2006
2619
  if (!this.isMouseDown || !this.currentDragItem) return;
2007
2620
 
2621
+ this.stopAutoScroll();
2622
+
2008
2623
  // If we were actually dragging, handle the drag end
2009
2624
  if (this.isDragging) {
2010
- const deltaX = event.clientX - this.dragStartPos.x;
2011
- 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;
2012
2632
 
2013
2633
  // Determine what items were moved
2014
2634
  const itemsToMove =
@@ -2077,6 +2697,9 @@ export class Editor extends RapidElement {
2077
2697
  this.isMouseDown = false;
2078
2698
  this.currentDragItem = null;
2079
2699
  this.canvasMouseDown = false;
2700
+ this.autoScrollDeltaX = 0;
2701
+ this.autoScrollDeltaY = 0;
2702
+ this.lastMouseEvent = null;
2080
2703
  }
2081
2704
 
2082
2705
  private updateCanvasSize(): void {
@@ -2093,11 +2716,19 @@ export class Editor extends RapidElement {
2093
2716
  this.definition.nodes.forEach((node) => {
2094
2717
  const ui = this.definition._ui.nodes[node.uuid];
2095
2718
  if (ui && ui.position) {
2096
- const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
2719
+ const nodeElement = this.querySelector(
2720
+ `[id="${node.uuid}"]`
2721
+ ) as HTMLElement;
2097
2722
  if (nodeElement) {
2098
- const rect = nodeElement.getBoundingClientRect();
2099
- maxWidth = Math.max(maxWidth, ui.position.left + rect.width);
2100
- 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
+ );
2101
2732
  }
2102
2733
  }
2103
2734
  });
@@ -2153,8 +2784,8 @@ export class Editor extends RapidElement {
2153
2784
  }
2154
2785
 
2155
2786
  const canvasRect = canvas.getBoundingClientRect();
2156
- const relativeX = event.clientX - canvasRect.left - 10;
2157
- 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;
2158
2789
 
2159
2790
  // Snap position to grid
2160
2791
  const snappedLeft = snapToGrid(relativeX);
@@ -2163,10 +2794,17 @@ export class Editor extends RapidElement {
2163
2794
  // Show the canvas menu at the mouse position (use viewport coordinates)
2164
2795
  const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
2165
2796
  if (canvasMenu) {
2166
- canvasMenu.show(event.clientX, event.clientY, {
2167
- x: snappedLeft,
2168
- y: snappedTop
2169
- });
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
+ );
2170
2808
  }
2171
2809
  }
2172
2810
 
@@ -2196,6 +2834,11 @@ export class Editor extends RapidElement {
2196
2834
  const selection = event.detail as CanvasMenuSelection;
2197
2835
  const store = getStore();
2198
2836
 
2837
+ if (selection.action === 'reflow') {
2838
+ this.performReflow();
2839
+ return;
2840
+ }
2841
+
2199
2842
  if (selection.action === 'sticky') {
2200
2843
  // Create new sticky note
2201
2844
  store.getState().createStickyNote({
@@ -2604,8 +3247,9 @@ export class Editor extends RapidElement {
2604
3247
  }
2605
3248
 
2606
3249
  // Check for collisions and reflow in case node size changed
3250
+ const nodeUuid = updatedNode.uuid;
2607
3251
  requestAnimationFrame(() => {
2608
- this.checkCollisionsAndReflow([this.editingNode.uuid]);
3252
+ this.checkCollisionsAndReflow([nodeUuid]);
2609
3253
  });
2610
3254
  }
2611
3255
  }
@@ -2648,11 +3292,9 @@ export class Editor extends RapidElement {
2648
3292
 
2649
3293
  const canvasRect = canvas.getBoundingClientRect();
2650
3294
 
2651
- // calculate position relative to canvas
2652
- // canvasRect gives us the canvas position in the viewport, which already accounts for scroll
2653
- // so we just need mouseX/Y - canvasRect.left/top to get position within canvas
2654
- const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
2655
- 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;
2656
3298
 
2657
3299
  // Apply grid snapping only if requested (for final drop position)
2658
3300
  if (applyGridSnapping) {
@@ -3973,14 +4615,15 @@ export class Editor extends RapidElement {
3973
4615
  const editorCenterX = editorRect.width / 2;
3974
4616
  const editorCenterY = editorRect.height / 2;
3975
4617
 
3976
- // Get node position relative to the editor's scroll container
3977
- const nodeRect = nodeElement.getBoundingClientRect();
3978
- const nodeCenterX = nodeElement.offsetLeft + nodeRect.width / 2;
3979
- 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;
3980
4621
 
3981
- // Calculate the scroll position needed to center the node
3982
- const targetScrollX = nodeCenterX - editorCenterX;
3983
- 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;
3984
4627
 
3985
4628
  // Smooth scroll the editor container to the target position
3986
4629
  editor.scrollTo({
@@ -4030,8 +4673,9 @@ export class Editor extends RapidElement {
4030
4673
  <div
4031
4674
  id="grid"
4032
4675
  class="${this.viewingRevision ? 'viewing-revision' : ''}"
4033
- style="min-width:100%;width:${this.canvasSize
4034
- .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})"
4035
4679
  >
4036
4680
  <div
4037
4681
  id="canvas"
@@ -4114,6 +4758,55 @@ export class Editor extends RapidElement {
4114
4758
  <div class="save-indicator ${this.isSaving ? 'visible' : ''}">
4115
4759
  <temba-loading units="3" size="8"></temba-loading>
4116
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
+ : ''}
4117
4810
  </div>
4118
4811
 
4119
4812
  ${this.editingNode || this.editingAction