@nyaruka/temba-components 0.141.1 → 0.142.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/static/svg/index.svg +1 -1
  3. package/dist/temba-components.js +849 -655
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/Icons.js +3 -1
  6. package/out-tsc/src/Icons.js.map +1 -1
  7. package/out-tsc/src/display/Button.js +2 -2
  8. package/out-tsc/src/display/Button.js.map +1 -1
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasMenu.js +24 -1
  11. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  12. package/out-tsc/src/flow/CanvasNode.js +7 -2
  13. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  14. package/out-tsc/src/flow/Editor.js +654 -66
  15. package/out-tsc/src/flow/Editor.js.map +1 -1
  16. package/out-tsc/src/flow/NodeEditor.js +8 -5
  17. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  18. package/out-tsc/src/flow/Plumber.js +40 -28
  19. package/out-tsc/src/flow/Plumber.js.map +1 -1
  20. package/out-tsc/src/flow/actions/send_msg.js +2 -1
  21. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  22. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -1
  23. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  24. package/out-tsc/src/flow/reflow.js +393 -0
  25. package/out-tsc/src/flow/reflow.js.map +1 -0
  26. package/out-tsc/src/flow/types.js.map +1 -1
  27. package/out-tsc/src/flow/utils.js +18 -3
  28. package/out-tsc/src/flow/utils.js.map +1 -1
  29. package/out-tsc/src/form/Compose.js +5 -0
  30. package/out-tsc/src/form/Compose.js.map +1 -1
  31. package/out-tsc/src/form/FieldRenderer.js +1 -3
  32. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  33. package/out-tsc/src/layout/Dialog.js +2 -0
  34. package/out-tsc/src/layout/Dialog.js.map +1 -1
  35. package/out-tsc/src/list/SortableList.js +39 -19
  36. package/out-tsc/src/list/SortableList.js.map +1 -1
  37. package/out-tsc/src/utils.js +5 -12
  38. package/out-tsc/src/utils.js.map +1 -1
  39. package/out-tsc/test/nodes/split_by_run_result.test.js +1 -2
  40. package/out-tsc/test/nodes/split_by_run_result.test.js.map +1 -1
  41. package/out-tsc/test/temba-canvas-menu.test.js +44 -0
  42. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  43. package/out-tsc/test/temba-flow-collision.test.js +25 -0
  44. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  45. package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
  46. package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
  47. package/out-tsc/test/temba-flow-editor.test.js +145 -1
  48. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  49. package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
  50. package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
  51. package/out-tsc/test/temba-flow-plumber.test.js +31 -0
  52. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  53. package/out-tsc/test/temba-flow-reflow.test.js +472 -0
  54. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
  55. package/out-tsc/test/temba-sortable-list.test.js +93 -0
  56. package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
  57. package/package.json +2 -2
  58. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  59. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  60. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  61. package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
  62. package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
  63. package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
  67. package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
  68. package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
  69. package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
  70. package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
  71. package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
  72. package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
  73. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  74. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  75. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  76. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  77. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  78. package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
  79. package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
  80. package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
  81. package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
  82. package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
  83. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  84. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  85. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  86. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  87. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  88. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  89. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  90. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  92. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  93. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  94. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  95. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  96. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  97. package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
  98. package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
  99. package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
  100. package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
  101. package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
  102. package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
  103. package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
  104. package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
  105. package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
  106. package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
  107. package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
  108. package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
  109. package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
  110. package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
  111. package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
  112. package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
  113. package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
  114. package/screenshots/truth/list/fields-dragging.png +0 -0
  115. package/screenshots/truth/list/sortable-dragging.png +0 -0
  116. package/screenshots/truth/modax/simple.png +0 -0
  117. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  118. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  124. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  125. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  126. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  127. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  128. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  129. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/src/Icons.ts +3 -1
  139. package/src/display/Button.ts +2 -2
  140. package/src/display/FloatingTab.ts +1 -1
  141. package/src/flow/CanvasMenu.ts +28 -3
  142. package/src/flow/CanvasNode.ts +7 -2
  143. package/src/flow/Editor.ts +755 -75
  144. package/src/flow/NodeEditor.ts +8 -4
  145. package/src/flow/Plumber.ts +65 -35
  146. package/src/flow/actions/send_msg.ts +2 -1
  147. package/src/flow/nodes/wait_for_response.ts +1 -1
  148. package/src/flow/reflow.ts +534 -0
  149. package/src/flow/types.ts +1 -0
  150. package/src/flow/utils.ts +19 -3
  151. package/src/form/Compose.ts +5 -0
  152. package/src/form/FieldRenderer.ts +1 -3
  153. package/src/layout/Dialog.ts +2 -0
  154. package/src/list/SortableList.ts +40 -19
  155. package/src/utils.ts +5 -12
  156. package/static/svg/index.svg +1 -1
  157. package/static/svg/work/traced/expand-06.svg +1 -0
  158. package/static/svg/work/used/expand-06.svg +3 -0
  159. package/test/nodes/split_by_run_result.test.ts +1 -2
  160. package/test/temba-canvas-menu.test.ts +55 -0
  161. package/test/temba-flow-collision.test.ts +31 -0
  162. package/test/temba-flow-editor-zoom.test.ts +583 -0
  163. package/test/temba-flow-editor.test.ts +187 -1
  164. package/test/temba-flow-node-drag.test.ts +171 -0
  165. package/test/temba-flow-plumber.test.ts +38 -0
  166. package/test/temba-flow-reflow.test.ts +703 -0
  167. package/test/temba-sortable-list.test.ts +120 -0
  168. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  169. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  170. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  171. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  172. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  173. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  174. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  175. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  176. package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
  177. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  178. package/screenshots/truth/compose/attachments-with-failures.png +0 -0
  179. package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
  180. package/screenshots/truth/contacts/tickets-assignment.png +0 -0
  181. package/screenshots/truth/contacts/tickets.png +0 -0
  182. package/screenshots/truth/flow/editor-basic.png +0 -0
  183. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  184. package/screenshots/truth/formfield/no-errors.png +0 -0
  185. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  186. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  187. package/screenshots/truth/omnibox/selected.png +0 -0
  188. package/screenshots/truth/select/enabled-multi-selection.png +0 -0
  189. package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
  190. package/screenshots/truth/select/endpoint-initial-value.png +0 -0
  191. package/screenshots/truth/select/initial-value.png +0 -0
  192. package/screenshots/truth/select/multi-reorder-final.png +0 -0
  193. package/screenshots/truth/select/multi-reorder-initial.png +0 -0
  194. package/screenshots/truth/select/selected-multi-test.png +0 -0
  195. package/screenshots/truth/select/value-initial.png +0 -0
  196. package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
  197. package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
  198. package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
  199. package/screenshots/truth/webchat/connecting-state.png +0 -0
@@ -1,7 +1,7 @@
1
1
  import { html, fixture, expect } from '@open-wc/testing';
2
2
  import { Editor } from '../src/flow/Editor';
3
3
  import { Plumber } from '../src/flow/Plumber';
4
- import { stub, restore } from 'sinon';
4
+ import { stub, restore, useFakeTimers } from 'sinon';
5
5
  import { zustand } from '../src/store/AppState';
6
6
  import { TEMBA_COMPONENTS_VERSION } from '../src/version';
7
7
 
@@ -1086,4 +1086,190 @@ describe('Editor', () => {
1086
1086
  ).to.equal('Save failed with status 403.');
1087
1087
  });
1088
1088
  });
1089
+
1090
+ describe('reflow card', () => {
1091
+ let clock: any;
1092
+
1093
+ beforeEach(() => {
1094
+ clock = useFakeTimers();
1095
+ });
1096
+
1097
+ afterEach(() => {
1098
+ clock.restore();
1099
+ });
1100
+
1101
+ it('renders only Discard button and meter when reflowUnsaved', async () => {
1102
+ editor = await fixture(html`
1103
+ <temba-flow-editor>
1104
+ <div id="canvas"></div>
1105
+ </temba-flow-editor>
1106
+ `);
1107
+
1108
+ (editor as any).canvasSize = { width: 800, height: 600 };
1109
+ (editor as any).reflowUnsaved = true;
1110
+ await editor.updateComplete;
1111
+
1112
+ const card = editor.querySelector('.reflow-card');
1113
+ expect(card).to.exist;
1114
+
1115
+ const discardBtn = card.querySelector('.reflow-discard');
1116
+ expect(discardBtn).to.exist;
1117
+ expect(discardBtn.textContent.trim()).to.equal('Discard');
1118
+
1119
+ const saveBtn = card.querySelector('.reflow-save');
1120
+ expect(saveBtn).to.not.exist;
1121
+
1122
+ const meter = card.querySelector('.reflow-meter');
1123
+ expect(meter).to.exist;
1124
+
1125
+ const meterFill = card.querySelector('.reflow-meter-fill');
1126
+ expect(meterFill).to.exist;
1127
+ });
1128
+
1129
+ it('does not render reflow card when reflowUnsaved is false', async () => {
1130
+ editor = await fixture(html`
1131
+ <temba-flow-editor>
1132
+ <div id="canvas"></div>
1133
+ </temba-flow-editor>
1134
+ `);
1135
+
1136
+ (editor as any).canvasSize = { width: 800, height: 600 };
1137
+ (editor as any).reflowUnsaved = false;
1138
+ await editor.updateComplete;
1139
+
1140
+ const card = editor.querySelector('.reflow-card');
1141
+ expect(card).to.not.exist;
1142
+ });
1143
+
1144
+ it('auto-saves after countdown expires', async () => {
1145
+ editor = await fixture(html`
1146
+ <temba-flow-editor>
1147
+ <div id="canvas"></div>
1148
+ </temba-flow-editor>
1149
+ `);
1150
+
1151
+ const saveStub = stub(editor as any, 'saveChanges').resolves();
1152
+ (editor as any).reflowUnsaved = true;
1153
+ (editor as any).savedReflowPositions = { 'node-1': { left: 0, top: 0 } };
1154
+
1155
+ // Start the auto-save timer
1156
+ (editor as any).clearReflowAutoSaveTimer();
1157
+ (editor as any).reflowAutoSaveTimer = setTimeout(() => {
1158
+ (editor as any).reflowAutoSaveTimer = null;
1159
+ if ((editor as any).reflowUnsaved) {
1160
+ (editor as any).reflowUnsaved = false;
1161
+ (editor as any).savedReflowPositions = null;
1162
+ (editor as any).saveChanges();
1163
+ }
1164
+ }, 5000);
1165
+
1166
+ // Advance time just before the deadline
1167
+ clock.tick(4999);
1168
+ expect(saveStub).to.not.have.been.called;
1169
+ expect((editor as any).reflowUnsaved).to.be.true;
1170
+
1171
+ // Advance past the deadline
1172
+ clock.tick(1);
1173
+ expect(saveStub).to.have.been.calledOnce;
1174
+ expect((editor as any).reflowUnsaved).to.be.false;
1175
+ expect((editor as any).savedReflowPositions).to.be.null;
1176
+
1177
+ saveStub.restore();
1178
+ });
1179
+
1180
+ it('cancels auto-save timer when Discard is clicked', async () => {
1181
+ editor = await fixture(html`
1182
+ <temba-flow-editor>
1183
+ <div id="canvas"></div>
1184
+ </temba-flow-editor>
1185
+ `);
1186
+
1187
+ const saveStub = stub(editor as any, 'saveChanges').resolves();
1188
+ (editor as any).reflowUnsaved = true;
1189
+ // Don't set savedReflowPositions — we're testing timer cancellation,
1190
+ // not the position-revert logic (which needs the store).
1191
+
1192
+ // Start the auto-save timer
1193
+ (editor as any).reflowAutoSaveTimer = setTimeout(() => {
1194
+ (editor as any).saveChanges();
1195
+ }, 5000);
1196
+
1197
+ // Click discard
1198
+ (editor as any).handleReflowDiscard();
1199
+
1200
+ expect((editor as any).reflowUnsaved).to.be.false;
1201
+ expect((editor as any).reflowAutoSaveTimer).to.be.null;
1202
+
1203
+ // Advance past the original deadline — save should NOT fire
1204
+ clock.tick(6000);
1205
+ expect(saveStub).to.not.have.been.called;
1206
+
1207
+ saveStub.restore();
1208
+ });
1209
+
1210
+ it('cancels auto-save timer when a normal edit dismisses card', async () => {
1211
+ editor = await fixture(html`
1212
+ <temba-flow-editor>
1213
+ <div id="canvas"></div>
1214
+ </temba-flow-editor>
1215
+ `);
1216
+
1217
+ const saveStub = stub(editor as any, 'saveChanges').resolves();
1218
+ (editor as any).reflowUnsaved = true;
1219
+ (editor as any).savedReflowPositions = { 'node-1': { left: 0, top: 0 } };
1220
+
1221
+ // Start the auto-save timer
1222
+ (editor as any).reflowAutoSaveTimer = setTimeout(() => {
1223
+ (editor as any).saveChanges();
1224
+ }, 5000);
1225
+
1226
+ // Simulate a normal edit triggering the dirtyDate handler
1227
+ (editor as any).dirtyDate = new Date();
1228
+ const changes = new Map();
1229
+ changes.set('dirtyDate', null);
1230
+ (editor as any).updated(changes);
1231
+
1232
+ expect((editor as any).reflowUnsaved).to.be.false;
1233
+ expect((editor as any).reflowAutoSaveTimer).to.be.null;
1234
+
1235
+ // The debouncedSave from the normal edit will call saveChanges,
1236
+ // but the reflow timer should not fire separately
1237
+ clock.tick(6000);
1238
+
1239
+ saveStub.restore();
1240
+ });
1241
+
1242
+ it('clears auto-save timer in disconnectedCallback', async () => {
1243
+ editor = await fixture(html`
1244
+ <temba-flow-editor>
1245
+ <div id="canvas"></div>
1246
+ </temba-flow-editor>
1247
+ `);
1248
+
1249
+ // Start the auto-save timer
1250
+ (editor as any).reflowAutoSaveTimer = setTimeout(() => {}, 5000);
1251
+ expect((editor as any).reflowAutoSaveTimer).to.not.be.null;
1252
+
1253
+ // Remove the editor from DOM
1254
+ editor.remove();
1255
+
1256
+ expect((editor as any).reflowAutoSaveTimer).to.be.null;
1257
+ });
1258
+
1259
+ it('clears existing timer when performReflow is called again', () => {
1260
+ editor = new Editor();
1261
+
1262
+ // Set up a first timer
1263
+ const firstTimer = setTimeout(() => {}, 5000);
1264
+ (editor as any).reflowAutoSaveTimer = firstTimer;
1265
+
1266
+ // Call clearReflowAutoSaveTimer (which performReflow calls)
1267
+ (editor as any).clearReflowAutoSaveTimer();
1268
+
1269
+ expect((editor as any).reflowAutoSaveTimer).to.be.null;
1270
+
1271
+ // The old timer should be cleared — advancing time should not fire it
1272
+ clock.tick(6000);
1273
+ });
1274
+ });
1089
1275
  });
@@ -334,4 +334,175 @@ describe('temba-flow-node drag and drop functionality', () => {
334
334
  document.removeEventListener('mousemove', handleMouseMove);
335
335
  document.removeEventListener('mouseup', handleMouseUp);
336
336
  });
337
+
338
+ describe('auto-scroll during drag', () => {
339
+ const AUTO_SCROLL_EDGE_ZONE = 100;
340
+ const AUTO_SCROLL_MAX_SPEED = 15;
341
+
342
+ function calculateScrollSpeed(
343
+ mousePos: number,
344
+ edgeStart: number,
345
+ edgeEnd: number
346
+ ): { dx: number; dy: number } {
347
+ const dx = 0;
348
+ const dy = 0;
349
+
350
+ // Left/top edge
351
+ const distFromStart = mousePos - edgeStart;
352
+ if (distFromStart >= 0 && distFromStart < AUTO_SCROLL_EDGE_ZONE) {
353
+ const ratio = 1 - distFromStart / AUTO_SCROLL_EDGE_ZONE;
354
+ return { dx: -(ratio * AUTO_SCROLL_MAX_SPEED), dy: 0 };
355
+ }
356
+
357
+ // Right/bottom edge
358
+ const distFromEnd = edgeEnd - mousePos;
359
+ if (distFromEnd >= 0 && distFromEnd < AUTO_SCROLL_EDGE_ZONE) {
360
+ const ratio = 1 - distFromEnd / AUTO_SCROLL_EDGE_ZONE;
361
+ return { dx: ratio * AUTO_SCROLL_MAX_SPEED, dy: 0 };
362
+ }
363
+
364
+ return { dx, dy };
365
+ }
366
+
367
+ it('should calculate scroll speed based on distance to edge', () => {
368
+ const edgeStart = 0;
369
+ const edgeEnd = 800;
370
+
371
+ // At the very left edge (distance = 0), speed should be max
372
+ let result = calculateScrollSpeed(0, edgeStart, edgeEnd);
373
+ assert.equal(result.dx, -AUTO_SCROLL_MAX_SPEED);
374
+
375
+ // At the edge zone boundary, speed should be 0
376
+ result = calculateScrollSpeed(AUTO_SCROLL_EDGE_ZONE, edgeStart, edgeEnd);
377
+ assert.equal(result.dx, 0);
378
+
379
+ // Halfway into the zone, speed should be half of max
380
+ result = calculateScrollSpeed(
381
+ AUTO_SCROLL_EDGE_ZONE / 2,
382
+ edgeStart,
383
+ edgeEnd
384
+ );
385
+ assert.closeTo(result.dx, -AUTO_SCROLL_MAX_SPEED / 2, 0.01);
386
+
387
+ // At the very right edge (distance = 0), speed should be max positive
388
+ result = calculateScrollSpeed(800, edgeStart, edgeEnd);
389
+ assert.equal(result.dx, AUTO_SCROLL_MAX_SPEED);
390
+
391
+ // Halfway into the right edge zone
392
+ result = calculateScrollSpeed(
393
+ edgeEnd - AUTO_SCROLL_EDGE_ZONE / 2,
394
+ edgeStart,
395
+ edgeEnd
396
+ );
397
+ assert.closeTo(result.dx, AUTO_SCROLL_MAX_SPEED / 2, 0.01);
398
+
399
+ // In the middle of the viewport, no scrolling
400
+ result = calculateScrollSpeed(400, edgeStart, edgeEnd);
401
+ assert.equal(result.dx, 0);
402
+ });
403
+
404
+ it('should not scroll when mouse is outside the viewport', () => {
405
+ const edgeStart = 0;
406
+ const edgeEnd = 800;
407
+
408
+ // Mouse is to the left of the viewport
409
+ const result = calculateScrollSpeed(-10, edgeStart, edgeEnd);
410
+ assert.equal(result.dx, 0);
411
+ });
412
+
413
+ it('should account for scroll delta in drag position calculation', () => {
414
+ // Simulate the formula: deltaX = (clientX - startX) + autoScrollDeltaX
415
+ const dragStartX = 400;
416
+ const currentClientX = 450;
417
+ const autoScrollDeltaX = 200;
418
+
419
+ const deltaX = currentClientX - dragStartX + autoScrollDeltaX;
420
+
421
+ // Without auto-scroll, delta would be 50. With 200px of scroll, it's 250.
422
+ assert.equal(deltaX, 250);
423
+
424
+ const originalLeft = 100;
425
+ const newLeft = originalLeft + deltaX;
426
+ assert.equal(newLeft, 350);
427
+ });
428
+
429
+ it('should accumulate scroll deltas correctly over multiple frames', () => {
430
+ let autoScrollDeltaX = 0;
431
+ let autoScrollDeltaY = 0;
432
+
433
+ // Simulate several frames of scrolling
434
+ const scrollIncrements = [
435
+ { dx: 5, dy: 3 },
436
+ { dx: 8, dy: 6 },
437
+ { dx: 10, dy: 9 },
438
+ { dx: 0, dy: 12 } // only vertical scrolling
439
+ ];
440
+
441
+ scrollIncrements.forEach(({ dx, dy }) => {
442
+ autoScrollDeltaX += dx;
443
+ autoScrollDeltaY += dy;
444
+ });
445
+
446
+ assert.equal(autoScrollDeltaX, 23);
447
+ assert.equal(autoScrollDeltaY, 30);
448
+ });
449
+
450
+ it('should clamp scroll delta when at scroll boundaries', () => {
451
+ // Simulate a scroll container at its left boundary
452
+ let scrollLeft = 0;
453
+ const requestedDx = -10;
454
+
455
+ const beforeScrollLeft = scrollLeft;
456
+ scrollLeft = Math.max(0, scrollLeft + requestedDx);
457
+ const actualDx = scrollLeft - beforeScrollLeft;
458
+
459
+ // At the boundary, actual delta should be 0
460
+ assert.equal(actualDx, 0);
461
+
462
+ // Simulate a scroll container not at boundary
463
+ scrollLeft = 50;
464
+ const beforeScrollLeft2 = scrollLeft;
465
+ scrollLeft = Math.max(0, scrollLeft + requestedDx);
466
+ const actualDx2 = scrollLeft - beforeScrollLeft2;
467
+
468
+ // Should have scrolled the full amount
469
+ assert.equal(actualDx2, -10);
470
+ });
471
+
472
+ it('should reset scroll deltas after drag ends', () => {
473
+ let autoScrollDeltaX = 150;
474
+ let autoScrollDeltaY = 200;
475
+
476
+ // Simulate drag end reset
477
+ autoScrollDeltaX = 0;
478
+ autoScrollDeltaY = 0;
479
+
480
+ assert.equal(autoScrollDeltaX, 0);
481
+ assert.equal(autoScrollDeltaY, 0);
482
+ });
483
+
484
+ it('should handle simultaneous horizontal and vertical auto-scroll', () => {
485
+ // Simulate mouse in the bottom-right corner of the viewport
486
+ const edgeEnd = 800;
487
+ const mousePos = 780; // 20px from the right/bottom edge
488
+
489
+ const distFromEnd = edgeEnd - mousePos;
490
+ assert.isTrue(distFromEnd < AUTO_SCROLL_EDGE_ZONE);
491
+
492
+ const ratio = 1 - distFromEnd / AUTO_SCROLL_EDGE_ZONE;
493
+ const scrollSpeed = ratio * AUTO_SCROLL_MAX_SPEED;
494
+
495
+ // Both axes should get the same speed when equidistant from edges
496
+ assert.isAbove(scrollSpeed, 0);
497
+
498
+ // Apply to both axes
499
+ let autoScrollDeltaX = 0;
500
+ let autoScrollDeltaY = 0;
501
+ autoScrollDeltaX += scrollSpeed;
502
+ autoScrollDeltaY += scrollSpeed;
503
+
504
+ assert.equal(autoScrollDeltaX, autoScrollDeltaY);
505
+ assert.isAbove(autoScrollDeltaX, 0);
506
+ });
507
+ });
337
508
  });
@@ -120,6 +120,44 @@ describe('Plumber', () => {
120
120
  expect((plumber as any).sources.size).to.equal(0);
121
121
  });
122
122
  });
123
+
124
+ describe('zoom property', () => {
125
+ it('defaults to 1.0', () => {
126
+ expect(plumber.zoom).to.equal(1.0);
127
+ });
128
+
129
+ it('is writable', () => {
130
+ plumber.zoom = 0.75;
131
+ expect(plumber.zoom).to.equal(0.75);
132
+ });
133
+ });
134
+
135
+ describe('toCanvas', () => {
136
+ it('returns identity at zoom 1.0', () => {
137
+ plumber.zoom = 1.0;
138
+ expect((plumber as any).toCanvas(100)).to.equal(100);
139
+ });
140
+
141
+ it('divides by zoom at 0.5', () => {
142
+ plumber.zoom = 0.5;
143
+ expect((plumber as any).toCanvas(100)).to.equal(200);
144
+ });
145
+
146
+ it('handles minimum zoom 0.1', () => {
147
+ plumber.zoom = 0.1;
148
+ expect((plumber as any).toCanvas(50)).to.equal(500);
149
+ });
150
+
151
+ it('handles negative viewport diffs', () => {
152
+ plumber.zoom = 0.5;
153
+ expect((plumber as any).toCanvas(-100)).to.equal(-200);
154
+ });
155
+
156
+ it('handles zero', () => {
157
+ plumber.zoom = 0.5;
158
+ expect((plumber as any).toCanvas(0)).to.equal(0);
159
+ });
160
+ });
123
161
  });
124
162
 
125
163
  describe('calculateFlowchartPath', () => {