@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
@@ -0,0 +1,583 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { Editor } from '../src/flow/Editor';
3
+ import { stub, restore, spy } from 'sinon';
4
+
5
+ customElements.define('temba-flow-editor-zoom', Editor);
6
+
7
+ /** Create an Editor instance with a mock plumber, suitable for unit-testing
8
+ * zoom methods without needing a full flow definition. */
9
+ const createEditorWithMockPlumber = (): Editor => {
10
+ const editor = new Editor();
11
+ (editor as any).plumber = {
12
+ zoom: 1,
13
+ repaintEverything: stub()
14
+ };
15
+ return editor;
16
+ };
17
+
18
+ describe('Editor Zoom', () => {
19
+ beforeEach(() => {
20
+ restore();
21
+ });
22
+
23
+ afterEach(() => {
24
+ restore();
25
+ });
26
+
27
+ // --- A. setZoom state management ---
28
+
29
+ describe('setZoom', () => {
30
+ it('clamps to minimum of 0.1', () => {
31
+ const editor = createEditorWithMockPlumber();
32
+ (editor as any).zoom = 0.5;
33
+ (editor as any).setZoom(0.02);
34
+ expect((editor as any).zoom).to.equal(0.1);
35
+ });
36
+
37
+ it('clamps to maximum of 1.0', () => {
38
+ const editor = createEditorWithMockPlumber();
39
+ (editor as any).zoom = 0.5;
40
+ (editor as any).setZoom(1.5);
41
+ expect((editor as any).zoom).to.equal(1.0);
42
+ });
43
+
44
+ it('rounds to 2 decimal places', () => {
45
+ const editor = createEditorWithMockPlumber();
46
+ (editor as any).zoom = 0.5;
47
+ (editor as any).setZoom(0.333);
48
+ expect((editor as any).zoom).to.equal(0.33);
49
+ });
50
+
51
+ it('rounds 0.555 to 0.56', () => {
52
+ const editor = createEditorWithMockPlumber();
53
+ (editor as any).zoom = 0.5;
54
+ (editor as any).setZoom(0.555);
55
+ expect((editor as any).zoom).to.equal(0.56);
56
+ });
57
+
58
+ it('is a no-op when clamped value equals current zoom', () => {
59
+ const editor = createEditorWithMockPlumber();
60
+ (editor as any).zoom = 0.5;
61
+ const rafSpy = spy(window, 'requestAnimationFrame');
62
+ (editor as any).setZoom(0.5);
63
+ expect((editor as any).zoom).to.equal(0.5);
64
+ expect(rafSpy).to.not.have.been.called;
65
+ rafSpy.restore();
66
+ });
67
+
68
+ it('syncs plumber.zoom', () => {
69
+ const editor = createEditorWithMockPlumber();
70
+ (editor as any).zoom = 0.5;
71
+ (editor as any).setZoom(0.75);
72
+ expect((editor as any).plumber.zoom).to.equal(0.75);
73
+ });
74
+
75
+ it('clears zoomFitted flag', () => {
76
+ const editor = createEditorWithMockPlumber();
77
+ (editor as any).zoom = 0.5;
78
+ (editor as any).zoomFitted = true;
79
+ (editor as any).setZoom(0.8);
80
+ expect((editor as any).zoomFitted).to.be.false;
81
+ });
82
+
83
+ it('preserves viewport center point when center is provided', () => {
84
+ const editor = createEditorWithMockPlumber();
85
+ (editor as any).zoom = 1.0;
86
+
87
+ // Create mock #editor element with known geometry
88
+ const mockEditor = {
89
+ scrollLeft: 100,
90
+ scrollTop: 200,
91
+ getBoundingClientRect: () => ({
92
+ left: 50,
93
+ top: 50,
94
+ width: 800,
95
+ height: 600
96
+ })
97
+ };
98
+ stub(editor, 'querySelector').returns(mockEditor as any);
99
+
100
+ // Stub rAF to invoke callback synchronously
101
+ const rafStub = stub(window, 'requestAnimationFrame').callsFake(
102
+ (cb: FrameRequestCallback) => {
103
+ cb(0);
104
+ return 0;
105
+ }
106
+ );
107
+
108
+ const center = { clientX: 450, clientY: 350 };
109
+ (editor as any).setZoom(0.5, center);
110
+
111
+ // Verify scroll math:
112
+ // ox = 450 - 50 = 400, oy = 350 - 50 = 300
113
+ // cx = (100 + 400) / 1.0 = 500, cy = (200 + 300) / 1.0 = 500
114
+ // newScrollLeft = 500 * 0.5 - 400 = -150
115
+ // newScrollTop = 500 * 0.5 - 300 = -50
116
+ expect(mockEditor.scrollLeft).to.equal(-150);
117
+ expect(mockEditor.scrollTop).to.equal(-50);
118
+
119
+ rafStub.restore();
120
+ });
121
+ });
122
+
123
+ // --- B. Convenience methods ---
124
+
125
+ describe('zoomIn', () => {
126
+ it('increments zoom by 0.05', () => {
127
+ const editor = createEditorWithMockPlumber();
128
+ (editor as any).zoom = 0.5;
129
+ (editor as any).zoomIn();
130
+ expect((editor as any).zoom).to.equal(0.55);
131
+ });
132
+
133
+ it('stays at 1.0 when already at maximum', () => {
134
+ const editor = createEditorWithMockPlumber();
135
+ (editor as any).zoom = 1.0;
136
+ (editor as any).zoomIn();
137
+ expect((editor as any).zoom).to.equal(1.0);
138
+ });
139
+ });
140
+
141
+ describe('zoomOut', () => {
142
+ it('decrements zoom by 0.05', () => {
143
+ const editor = createEditorWithMockPlumber();
144
+ (editor as any).zoom = 0.5;
145
+ (editor as any).zoomOut();
146
+ expect((editor as any).zoom).to.equal(0.45);
147
+ });
148
+
149
+ it('stays at 0.1 when already at minimum', () => {
150
+ const editor = createEditorWithMockPlumber();
151
+ (editor as any).zoom = 0.1;
152
+ (editor as any).zoomOut();
153
+ expect((editor as any).zoom).to.equal(0.1);
154
+ });
155
+ });
156
+
157
+ describe('zoomToFull', () => {
158
+ it('resets zoom to 1.0', () => {
159
+ const editor = createEditorWithMockPlumber();
160
+ (editor as any).zoom = 0.4;
161
+ (editor as any).zoomToFull();
162
+ expect((editor as any).zoom).to.equal(1.0);
163
+ });
164
+ });
165
+
166
+ // --- C. zoomToFit ---
167
+
168
+ describe('zoomToFit', () => {
169
+ it('returns early when no definition', () => {
170
+ const editor = createEditorWithMockPlumber();
171
+ (editor as any).definition = null;
172
+ (editor as any).zoomToFit();
173
+ expect((editor as any).zoom).to.equal(1.0);
174
+ });
175
+
176
+ it('returns early when definition has zero nodes', () => {
177
+ const editor = createEditorWithMockPlumber();
178
+ (editor as any).definition = { nodes: [], _ui: { nodes: {} } };
179
+ (editor as any).zoomToFit();
180
+ expect((editor as any).zoom).to.equal(1.0);
181
+ });
182
+
183
+ it('sets zoomFitted to true', () => {
184
+ const editor = createEditorWithMockPlumber();
185
+
186
+ // Create mock definition with nodes spread wide enough for zoom < 1.0
187
+ const node1uuid = 'node-1-uuid';
188
+ const node2uuid = 'node-2-uuid';
189
+ (editor as any).definition = {
190
+ nodes: [{ uuid: node1uuid }, { uuid: node2uuid }],
191
+ _ui: {
192
+ nodes: {
193
+ [node1uuid]: { position: { left: 0, top: 0 } },
194
+ [node2uuid]: { position: { left: 2000, top: 1500 } }
195
+ },
196
+ stickies: {}
197
+ }
198
+ };
199
+
200
+ // Create mock node elements
201
+ const el1 = document.createElement('div');
202
+ el1.id = node1uuid;
203
+ el1.style.width = '200px';
204
+ el1.style.height = '100px';
205
+ document.body.appendChild(el1);
206
+
207
+ const el2 = document.createElement('div');
208
+ el2.id = node2uuid;
209
+ el2.style.width = '200px';
210
+ el2.style.height = '100px';
211
+ document.body.appendChild(el2);
212
+
213
+ // Create mock #editor element
214
+ const mockEditor = document.createElement('div');
215
+ mockEditor.id = 'mock-editor-for-zoom';
216
+ Object.defineProperty(mockEditor, 'clientWidth', { value: 800 });
217
+ Object.defineProperty(mockEditor, 'clientHeight', { value: 600 });
218
+ mockEditor.scrollLeft = 0;
219
+ mockEditor.scrollTop = 0;
220
+
221
+ stub(editor, 'querySelector').callsFake((selector: string) => {
222
+ if (selector === '#editor') return mockEditor;
223
+ if (selector.includes(node1uuid)) return el1;
224
+ if (selector.includes(node2uuid)) return el2;
225
+ return null;
226
+ });
227
+
228
+ stub(window, 'requestAnimationFrame').callsFake(
229
+ (cb: FrameRequestCallback) => {
230
+ cb(0);
231
+ return 0;
232
+ }
233
+ );
234
+
235
+ (editor as any).zoomToFit();
236
+ expect((editor as any).zoomFitted).to.be.true;
237
+
238
+ el1.remove();
239
+ el2.remove();
240
+ });
241
+
242
+ it('caps zoom at 1.0 when nodes fit easily', () => {
243
+ const editor = createEditorWithMockPlumber();
244
+
245
+ // Tiny content that fits easily
246
+ const nodeUuid = 'small-node';
247
+ (editor as any).definition = {
248
+ nodes: [{ uuid: nodeUuid }],
249
+ _ui: {
250
+ nodes: {
251
+ [nodeUuid]: { position: { left: 100, top: 100 } }
252
+ },
253
+ stickies: {}
254
+ }
255
+ };
256
+
257
+ const el = document.createElement('div');
258
+ el.id = nodeUuid;
259
+ el.style.width = '50px';
260
+ el.style.height = '30px';
261
+ document.body.appendChild(el);
262
+
263
+ const mockEditor = document.createElement('div');
264
+ Object.defineProperty(mockEditor, 'clientWidth', { value: 800 });
265
+ Object.defineProperty(mockEditor, 'clientHeight', { value: 600 });
266
+ mockEditor.scrollLeft = 0;
267
+ mockEditor.scrollTop = 0;
268
+
269
+ stub(editor, 'querySelector').callsFake((selector: string) => {
270
+ if (selector === '#editor') return mockEditor;
271
+ if (selector.includes(nodeUuid)) return el;
272
+ return null;
273
+ });
274
+
275
+ stub(window, 'requestAnimationFrame').callsFake(
276
+ (cb: FrameRequestCallback) => {
277
+ cb(0);
278
+ return 0;
279
+ }
280
+ );
281
+
282
+ (editor as any).zoomToFit();
283
+ expect((editor as any).zoom).to.equal(1.0);
284
+
285
+ el.remove();
286
+ });
287
+
288
+ it('rounds zoom to nearest 0.05', () => {
289
+ const editor = createEditorWithMockPlumber();
290
+
291
+ // Create nodes that produce a zoom that's not a multiple of 0.05
292
+ const node1uuid = 'fit-node-1';
293
+ const node2uuid = 'fit-node-2';
294
+ (editor as any).definition = {
295
+ nodes: [{ uuid: node1uuid }, { uuid: node2uuid }],
296
+ _ui: {
297
+ nodes: {
298
+ [node1uuid]: { position: { left: 0, top: 0 } },
299
+ [node2uuid]: { position: { left: 3000, top: 2000 } }
300
+ },
301
+ stickies: {}
302
+ }
303
+ };
304
+
305
+ const el1 = document.createElement('div');
306
+ el1.id = node1uuid;
307
+ el1.style.width = '200px';
308
+ el1.style.height = '100px';
309
+ document.body.appendChild(el1);
310
+
311
+ const el2 = document.createElement('div');
312
+ el2.id = node2uuid;
313
+ el2.style.width = '200px';
314
+ el2.style.height = '100px';
315
+ document.body.appendChild(el2);
316
+
317
+ const mockEditor = document.createElement('div');
318
+ Object.defineProperty(mockEditor, 'clientWidth', { value: 800 });
319
+ Object.defineProperty(mockEditor, 'clientHeight', { value: 600 });
320
+ mockEditor.scrollLeft = 0;
321
+ mockEditor.scrollTop = 0;
322
+
323
+ stub(editor, 'querySelector').callsFake((selector: string) => {
324
+ if (selector === '#editor') return mockEditor;
325
+ if (selector.includes(node1uuid)) return el1;
326
+ if (selector.includes(node2uuid)) return el2;
327
+ return null;
328
+ });
329
+
330
+ stub(window, 'requestAnimationFrame').callsFake(
331
+ (cb: FrameRequestCallback) => {
332
+ cb(0);
333
+ return 0;
334
+ }
335
+ );
336
+
337
+ (editor as any).zoomToFit();
338
+
339
+ const zoom = (editor as any).zoom;
340
+ // Zoom should be a multiple of 0.05 (within float tolerance)
341
+ const remainder = Math.round((zoom % 0.05) * 1000) / 1000;
342
+ expect(remainder === 0 || remainder === 0.05).to.be.true;
343
+
344
+ el1.remove();
345
+ el2.remove();
346
+ });
347
+ });
348
+
349
+ // --- D. handleWheel ---
350
+
351
+ describe('handleWheel', () => {
352
+ it('ignores non-ctrl/meta scroll', () => {
353
+ const editor = createEditorWithMockPlumber();
354
+ (editor as any).zoom = 0.5;
355
+
356
+ const event = new WheelEvent('wheel', {
357
+ deltaY: 100,
358
+ clientX: 400,
359
+ clientY: 300
360
+ });
361
+
362
+ (editor as any).handleWheel(event);
363
+ expect((editor as any).zoom).to.equal(0.5);
364
+ });
365
+
366
+ it('zooms out by 0.05 on Ctrl+scroll-down', () => {
367
+ const editor = createEditorWithMockPlumber();
368
+ (editor as any).zoom = 0.5;
369
+
370
+ // Stub querySelector for setZoom's editor lookup
371
+ stub(editor, 'querySelector').returns(null);
372
+
373
+ const event = new WheelEvent('wheel', {
374
+ ctrlKey: true,
375
+ deltaY: 100,
376
+ clientX: 400,
377
+ clientY: 300
378
+ });
379
+
380
+ (editor as any).handleWheel(event);
381
+ expect((editor as any).zoom).to.equal(0.45);
382
+ });
383
+
384
+ it('zooms in by 0.05 on Meta+scroll-up', () => {
385
+ const editor = createEditorWithMockPlumber();
386
+ (editor as any).zoom = 0.5;
387
+
388
+ stub(editor, 'querySelector').returns(null);
389
+
390
+ const event = new WheelEvent('wheel', {
391
+ metaKey: true,
392
+ deltaY: -100,
393
+ clientX: 400,
394
+ clientY: 300
395
+ });
396
+
397
+ (editor as any).handleWheel(event);
398
+ expect((editor as any).zoom).to.equal(0.55);
399
+ });
400
+
401
+ it('calls preventDefault on ctrl/meta wheel events', () => {
402
+ const editor = createEditorWithMockPlumber();
403
+ (editor as any).zoom = 0.5;
404
+
405
+ stub(editor, 'querySelector').returns(null);
406
+
407
+ const event = new WheelEvent('wheel', {
408
+ ctrlKey: true,
409
+ deltaY: 100,
410
+ clientX: 400,
411
+ clientY: 300,
412
+ cancelable: true
413
+ });
414
+
415
+ const preventDefaultSpy = spy(event, 'preventDefault');
416
+ (editor as any).handleWheel(event);
417
+ expect(preventDefaultSpy).to.have.been.calledOnce;
418
+ });
419
+ });
420
+
421
+ // --- E. Coordinate conversions ---
422
+
423
+ describe('coordinate conversions', () => {
424
+ it('handleCanvasContextMenu divides by zoom', () => {
425
+ const editor = createEditorWithMockPlumber();
426
+ (editor as any).zoom = 0.5;
427
+ (editor as any).viewingRevision = null;
428
+ (editor as any).isTranslating = false;
429
+ (editor as any).definition = { nodes: [{ uuid: 'n' }] };
430
+
431
+ // Track the canvas-space position passed to canvasMenu.show
432
+ // show() is called with: (clientX, clientY, {x: snappedLeft, y: snappedTop}, true, hasNodes)
433
+ let capturedPosition: any = null;
434
+ const mockCanvasMenu = {
435
+ show: (
436
+ _clientX: number,
437
+ _clientY: number,
438
+ position: any,
439
+ ..._rest: any[]
440
+ ) => {
441
+ capturedPosition = position;
442
+ }
443
+ };
444
+
445
+ const mockCanvas = {
446
+ getBoundingClientRect: () => ({
447
+ left: 0,
448
+ top: 0,
449
+ width: 1000,
450
+ height: 800
451
+ })
452
+ };
453
+
454
+ stub(editor, 'querySelector').callsFake(((selector: string) => {
455
+ if (selector === '#canvas') return mockCanvas;
456
+ if (selector === 'temba-canvas-menu') return mockCanvasMenu;
457
+ return null;
458
+ }) as any);
459
+
460
+ const event = {
461
+ clientX: 200,
462
+ clientY: 300,
463
+ target: { id: 'canvas' },
464
+ preventDefault: stub(),
465
+ stopPropagation: stub()
466
+ };
467
+
468
+ (editor as any).handleCanvasContextMenu(event);
469
+
470
+ // relativeX = (200 - 0) / 0.5 - 10 = 390, snapped to grid(20): 400
471
+ // relativeY = (300 - 0) / 0.5 - 10 = 590, snapped to grid(20): 600
472
+ expect(capturedPosition).to.not.be.null;
473
+ expect(capturedPosition.x).to.equal(400);
474
+ expect(capturedPosition.y).to.equal(600);
475
+ });
476
+
477
+ it('calculateCanvasDropPosition divides by zoom', () => {
478
+ const editor = createEditorWithMockPlumber();
479
+ (editor as any).zoom = 0.5;
480
+
481
+ const mockCanvas = {
482
+ getBoundingClientRect: () => ({
483
+ left: 0,
484
+ top: 0,
485
+ width: 1000,
486
+ height: 800
487
+ })
488
+ };
489
+
490
+ stub(editor, 'querySelector').callsFake(((selector: string) => {
491
+ if (selector === '#canvas') return mockCanvas;
492
+ return null;
493
+ }) as any);
494
+
495
+ // DROP_PREVIEW_OFFSET_X = 20, DROP_PREVIEW_OFFSET_Y = 20
496
+ const pos = (editor as any).calculateCanvasDropPosition(200, 300, true);
497
+
498
+ // left = (200 - 0) / 0.5 - 20 = 380, snapped: 380
499
+ // top = (300 - 0) / 0.5 - 20 = 580, snapped: 580
500
+ expect(pos.left).to.equal(380);
501
+ expect(pos.top).to.equal(580);
502
+ });
503
+
504
+ it('focusNode multiplies by zoom for scroll position', () => {
505
+ const editor = createEditorWithMockPlumber();
506
+ (editor as any).zoom = 0.5;
507
+
508
+ const mockNode = {
509
+ offsetLeft: 200,
510
+ offsetTop: 300,
511
+ offsetWidth: 100,
512
+ offsetHeight: 60
513
+ };
514
+
515
+ let scrollToArgs: any = null;
516
+ const mockEditor = {
517
+ getBoundingClientRect: () => ({
518
+ width: 800,
519
+ height: 600,
520
+ left: 0,
521
+ top: 0
522
+ }),
523
+ scrollTo: (args: any) => {
524
+ scrollToArgs = args;
525
+ }
526
+ };
527
+
528
+ stub(editor, 'querySelector').callsFake(((selector: string) => {
529
+ if (selector.includes('temba-flow-node')) return mockNode;
530
+ if (selector === '#editor') return mockEditor;
531
+ return null;
532
+ }) as any);
533
+
534
+ editor.focusNode('test-uuid');
535
+
536
+ // nodeCenterX = 200 + 50 = 250, nodeCenterY = 300 + 30 = 330
537
+ // targetScrollX = 250 * 0.5 - 400 = -275 -> max(0, -275) = 0
538
+ // targetScrollY = 330 * 0.5 - 300 = -135 -> max(0, -135) = 0
539
+ expect(scrollToArgs).to.not.be.null;
540
+ expect(scrollToArgs.left).to.equal(0);
541
+ expect(scrollToArgs.top).to.equal(0);
542
+ });
543
+
544
+ it('focusNode scroll positions scale with zoom for distant nodes', () => {
545
+ const editor = createEditorWithMockPlumber();
546
+ (editor as any).zoom = 0.5;
547
+
548
+ const mockNode = {
549
+ offsetLeft: 2000,
550
+ offsetTop: 1500,
551
+ offsetWidth: 200,
552
+ offsetHeight: 100
553
+ };
554
+
555
+ let scrollToArgs: any = null;
556
+ const mockEditor = {
557
+ getBoundingClientRect: () => ({
558
+ width: 800,
559
+ height: 600,
560
+ left: 0,
561
+ top: 0
562
+ }),
563
+ scrollTo: (args: any) => {
564
+ scrollToArgs = args;
565
+ }
566
+ };
567
+
568
+ stub(editor, 'querySelector').callsFake(((selector: string) => {
569
+ if (selector.includes('temba-flow-node')) return mockNode;
570
+ if (selector === '#editor') return mockEditor;
571
+ return null;
572
+ }) as any);
573
+
574
+ editor.focusNode('distant-node');
575
+
576
+ // nodeCenterX = 2000 + 100 = 2100, nodeCenterY = 1500 + 50 = 1550
577
+ // targetScrollX = 2100 * 0.5 - 400 = 650
578
+ // targetScrollY = 1550 * 0.5 - 300 = 475
579
+ expect(scrollToArgs.left).to.equal(650);
580
+ expect(scrollToArgs.top).to.equal(475);
581
+ });
582
+ });
583
+ });