@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,534 @@
1
+ import {
2
+ Node,
3
+ FlowPosition,
4
+ NodeUI,
5
+ StickyNote
6
+ } from '../store/flow-definition';
7
+ import { snapToGrid } from './utils';
8
+
9
+ const VERTICAL_GAP = 80;
10
+ const HORIZONTAL_GAP = 60;
11
+ const STICKY_GAP = 20;
12
+ const MAX_WIDTH = 1200;
13
+
14
+ interface NodeSize {
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ /**
20
+ * Calculates a layered layout for a flow, placing the start node at the
21
+ * upper-left and arranging the flow downward. Sibling nodes at splits
22
+ * share the same horizontal plane.
23
+ */
24
+ export function calculateLayeredLayout(
25
+ nodes: Node[],
26
+ nodeUIs: Record<string, NodeUI>,
27
+ startNodeUuid: string,
28
+ getNodeSize: (uuid: string) => NodeSize
29
+ ): Record<string, FlowPosition> {
30
+ if (nodes.length === 0) return {};
31
+
32
+ const nodeSet = new Set(nodes.map((n) => n.uuid));
33
+
34
+ // Build deduplicated adjacency lists
35
+ const children = new Map<string, string[]>();
36
+ const parents = new Map<string, string[]>();
37
+
38
+ for (const node of nodes) {
39
+ const seen = new Set<string>();
40
+ const childUuids: string[] = [];
41
+ for (const exit of node.exits) {
42
+ if (
43
+ exit.destination_uuid &&
44
+ nodeSet.has(exit.destination_uuid) &&
45
+ !seen.has(exit.destination_uuid)
46
+ ) {
47
+ seen.add(exit.destination_uuid);
48
+ childUuids.push(exit.destination_uuid);
49
+ const p = parents.get(exit.destination_uuid) || [];
50
+ p.push(node.uuid);
51
+ parents.set(exit.destination_uuid, p);
52
+ }
53
+ }
54
+ children.set(node.uuid, childUuids);
55
+ }
56
+
57
+ // Find back-edges via DFS so we can ignore cycles during layering
58
+ const backEdges = findBackEdges(startNodeUuid, children);
59
+
60
+ // Assign layers using longest-path on the DAG (ignoring back-edges)
61
+ const layers = assignLayers(
62
+ startNodeUuid,
63
+ nodes,
64
+ children,
65
+ parents,
66
+ backEdges
67
+ );
68
+
69
+ // Group nodes by layer
70
+ const layerGroups = new Map<number, string[]>();
71
+ for (const [uuid, layer] of layers) {
72
+ const group = layerGroups.get(layer) || [];
73
+ group.push(uuid);
74
+ layerGroups.set(layer, group);
75
+ }
76
+
77
+ // Order nodes within each layer using barycenter heuristic
78
+ const sortedLayers = Array.from(layerGroups.keys()).sort((a, b) => a - b);
79
+ orderNodesInLayers(sortedLayers, layerGroups, parents, layers);
80
+
81
+ // Gather sizes
82
+ const sizes = new Map<string, NodeSize>();
83
+ for (const node of nodes) {
84
+ sizes.set(node.uuid, getNodeSize(node.uuid));
85
+ }
86
+
87
+ // Compute positions
88
+ return computePositions(
89
+ sortedLayers,
90
+ layerGroups,
91
+ sizes,
92
+ parents,
93
+ layers,
94
+ startNodeUuid
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Finds back-edges (cycle-forming edges) via DFS from the start node.
100
+ * Returns a set of "parentUuid->childUuid" strings representing edges to ignore.
101
+ */
102
+ function findBackEdges(
103
+ startNodeUuid: string,
104
+ children: Map<string, string[]>
105
+ ): Set<string> {
106
+ const backEdges = new Set<string>();
107
+ const visiting = new Set<string>(); // currently on the DFS stack
108
+ const visited = new Set<string>(); // fully processed
109
+
110
+ function dfs(node: string): void {
111
+ visiting.add(node);
112
+ visited.add(node);
113
+
114
+ for (const child of children.get(node) || []) {
115
+ if (visiting.has(child)) {
116
+ backEdges.add(`${node}->${child}`);
117
+ } else if (!visited.has(child)) {
118
+ dfs(child);
119
+ }
120
+ }
121
+
122
+ visiting.delete(node);
123
+ }
124
+
125
+ dfs(startNodeUuid);
126
+ return backEdges;
127
+ }
128
+
129
+ /**
130
+ * Assigns layers using topological processing order on the DAG
131
+ * (back-edges removed). Each node's layer = max(parent layers) + 1,
132
+ * giving the longest-path assignment so merge nodes sit below all parents.
133
+ */
134
+ function assignLayers(
135
+ startNodeUuid: string,
136
+ nodes: Node[],
137
+ children: Map<string, string[]>,
138
+ parents: Map<string, string[]>,
139
+ backEdges: Set<string>
140
+ ): Map<string, number> {
141
+ const layers = new Map<string, number>();
142
+ layers.set(startNodeUuid, 0);
143
+
144
+ // Build forward in-degree (ignoring back-edges) for topological processing
145
+ const inDegree = new Map<string, number>();
146
+ for (const node of nodes) {
147
+ inDegree.set(node.uuid, 0);
148
+ }
149
+ for (const [parent, childList] of children) {
150
+ for (const child of childList) {
151
+ if (!backEdges.has(`${parent}->${child}`)) {
152
+ inDegree.set(child, (inDegree.get(child) || 0) + 1);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Process nodes in topological order (Kahn's algorithm)
158
+ // Start with nodes that have no forward in-edges
159
+ const queue: string[] = [];
160
+ for (const [uuid, deg] of inDegree) {
161
+ if (deg === 0) {
162
+ queue.push(uuid);
163
+ if (!layers.has(uuid)) {
164
+ layers.set(uuid, 0);
165
+ }
166
+ }
167
+ }
168
+
169
+ while (queue.length > 0) {
170
+ const current = queue.shift()!;
171
+ const currentLayer = layers.get(current)!;
172
+
173
+ for (const child of children.get(current) || []) {
174
+ if (backEdges.has(`${current}->${child}`)) continue;
175
+
176
+ // Longest path: child layer = max of all parent layers + 1
177
+ const newLayer = currentLayer + 1;
178
+ if (!layers.has(child) || newLayer > layers.get(child)!) {
179
+ layers.set(child, newLayer);
180
+ }
181
+
182
+ // Decrement in-degree; enqueue when all forward parents processed
183
+ const remaining = inDegree.get(child)! - 1;
184
+ inDegree.set(child, remaining);
185
+ if (remaining === 0) {
186
+ queue.push(child);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Handle unreachable nodes (not reachable from start)
192
+ const unreachable = nodes.filter((n) => !layers.has(n.uuid));
193
+ if (unreachable.length > 0) {
194
+ const maxLayer = Math.max(...Array.from(layers.values()), -1);
195
+ let unreachableLayer = maxLayer + 2;
196
+ for (const node of unreachable) {
197
+ layers.set(node.uuid, unreachableLayer);
198
+ unreachableLayer++;
199
+ }
200
+ }
201
+
202
+ return layers;
203
+ }
204
+
205
+ /**
206
+ * Orders nodes within each layer using a barycenter heuristic:
207
+ * each node is positioned based on the average index of its parents
208
+ * in layers above.
209
+ */
210
+ function orderNodesInLayers(
211
+ sortedLayers: number[],
212
+ layerGroups: Map<number, string[]>,
213
+ parents: Map<string, string[]>,
214
+ layers: Map<string, number>
215
+ ): void {
216
+ const indexInLayer = new Map<string, number>();
217
+
218
+ for (const layer of sortedLayers) {
219
+ const group = layerGroups.get(layer)!;
220
+
221
+ if (layer === sortedLayers[0]) {
222
+ group.forEach((uuid, idx) => indexInLayer.set(uuid, idx));
223
+ continue;
224
+ }
225
+
226
+ const barycenters: { uuid: string; value: number }[] = group.map((uuid) => {
227
+ // Only consider parents that are in layers above this one
228
+ const nodeParents = (parents.get(uuid) || []).filter((p) => {
229
+ const pl = layers.get(p);
230
+ return pl !== undefined && pl < layer;
231
+ });
232
+ if (nodeParents.length === 0) {
233
+ return { uuid, value: Infinity };
234
+ }
235
+ const sum = nodeParents.reduce((acc, p) => {
236
+ return acc + (indexInLayer.get(p) ?? 0);
237
+ }, 0);
238
+ return { uuid, value: sum / nodeParents.length };
239
+ });
240
+
241
+ barycenters.sort((a, b) => a.value - b.value);
242
+
243
+ const sorted = barycenters.map((b) => b.uuid);
244
+ layerGroups.set(layer, sorted);
245
+ sorted.forEach((uuid, idx) => indexInLayer.set(uuid, idx));
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Splits a layer's nodes into sub-rows that each fit within MAX_WIDTH.
251
+ */
252
+ function splitIntoRows(
253
+ group: string[],
254
+ sizes: Map<string, NodeSize>
255
+ ): string[][] {
256
+ const rows: string[][] = [];
257
+ let currentRow: string[] = [];
258
+ let currentWidth = 0;
259
+
260
+ for (const uuid of group) {
261
+ const nodeWidth = sizes.get(uuid)?.width || 200;
262
+ const additionalWidth =
263
+ currentRow.length > 0 ? HORIZONTAL_GAP + nodeWidth : nodeWidth;
264
+
265
+ if (currentRow.length > 0 && currentWidth + additionalWidth > MAX_WIDTH) {
266
+ rows.push(currentRow);
267
+ currentRow = [uuid];
268
+ currentWidth = nodeWidth;
269
+ } else {
270
+ currentRow.push(uuid);
271
+ currentWidth += additionalWidth;
272
+ }
273
+ }
274
+
275
+ if (currentRow.length > 0) {
276
+ rows.push(currentRow);
277
+ }
278
+
279
+ return rows;
280
+ }
281
+
282
+ /**
283
+ * Computes pixel positions for each node. Each node's ideal X is centered
284
+ * under its parent(s), with overlap resolution to prevent collisions.
285
+ * Layers that exceed MAX_WIDTH are split into multiple rows.
286
+ */
287
+ function computePositions(
288
+ sortedLayers: number[],
289
+ layerGroups: Map<number, string[]>,
290
+ sizes: Map<string, NodeSize>,
291
+ parents: Map<string, string[]>,
292
+ layers: Map<string, number>,
293
+ startNodeUuid: string
294
+ ): Record<string, FlowPosition> {
295
+ const positions: Record<string, FlowPosition> = {};
296
+ let currentTop = 0;
297
+
298
+ for (const layer of sortedLayers) {
299
+ const group = layerGroups.get(layer)!;
300
+ const subRows = splitIntoRows(group, sizes);
301
+
302
+ for (const subRow of subRows) {
303
+ const top = snapToGrid(currentTop);
304
+
305
+ if (layer === sortedLayers[0]) {
306
+ // First layer: start node at top, others nudged down two grid squares
307
+ let x = 0;
308
+ for (const uuid of subRow) {
309
+ const nodeTop = uuid === startNodeUuid ? top : top + 40;
310
+ positions[uuid] = { left: snapToGrid(x), top: nodeTop };
311
+ x += (sizes.get(uuid)?.width || 200) + HORIZONTAL_GAP;
312
+ }
313
+ } else {
314
+ // Compute total width of this sub-row
315
+ let totalWidth = 0;
316
+ for (const uuid of subRow) {
317
+ totalWidth += sizes.get(uuid)?.width || 200;
318
+ }
319
+ totalWidth += HORIZONTAL_GAP * (subRow.length - 1);
320
+
321
+ // Find the center point to place this sub-row under: midpoint of
322
+ // the span of all parent centers for nodes in this sub-row
323
+ const parentCenters: number[] = [];
324
+ for (const uuid of subRow) {
325
+ const nodeParents = (parents.get(uuid) || []).filter((p) => {
326
+ const pl = layers.get(p);
327
+ return pl !== undefined && pl < layer;
328
+ });
329
+ for (const pUuid of nodeParents) {
330
+ const parentPos = positions[pUuid];
331
+ if (parentPos) {
332
+ const parentWidth = sizes.get(pUuid)?.width || 200;
333
+ parentCenters.push(parentPos.left + parentWidth / 2);
334
+ }
335
+ }
336
+ }
337
+
338
+ // Center the sub-row under the parent span, anchored left if not enough room
339
+ let rowLeft: number;
340
+ if (parentCenters.length > 0) {
341
+ const spanCenter =
342
+ (Math.min(...parentCenters) + Math.max(...parentCenters)) / 2;
343
+ rowLeft = Math.max(0, spanCenter - totalWidth / 2);
344
+ } else {
345
+ rowLeft = 0;
346
+ }
347
+
348
+ // Place nodes left-to-right starting from rowLeft
349
+ let x = rowLeft;
350
+ for (const uuid of subRow) {
351
+ const nodeWidth = sizes.get(uuid)?.width || 200;
352
+ positions[uuid] = { left: snapToGrid(x), top };
353
+ x = snapToGrid(x) + nodeWidth + HORIZONTAL_GAP;
354
+ }
355
+ }
356
+
357
+ // Advance past this sub-row
358
+ const maxHeight = Math.max(
359
+ ...subRow.map((uuid) => sizes.get(uuid)?.height || 100)
360
+ );
361
+ currentTop = top + maxHeight + VERTICAL_GAP;
362
+ }
363
+ }
364
+
365
+ // Shift everything so the start node is at (0, 0)
366
+ const startPos = positions[startNodeUuid];
367
+ if (startPos) {
368
+ const offsetX = startPos.left;
369
+ const offsetY = startPos.top;
370
+
371
+ for (const uuid of Object.keys(positions)) {
372
+ positions[uuid] = {
373
+ left: snapToGrid(Math.max(0, positions[uuid].left - offsetX)),
374
+ top: snapToGrid(Math.max(0, positions[uuid].top - offsetY))
375
+ };
376
+ }
377
+ }
378
+
379
+ return positions;
380
+ }
381
+
382
+ interface StickySize {
383
+ width: number;
384
+ height: number;
385
+ }
386
+
387
+ /**
388
+ * Places sticky notes next to the node they were closest to before reflow.
389
+ * If a sticky was to the left of the start node, it is placed to the right instead.
390
+ */
391
+ export function placeStickyNotes(
392
+ stickies: Record<string, StickyNote>,
393
+ oldNodePositions: Record<string, FlowPosition>,
394
+ newNodePositions: Record<string, FlowPosition>,
395
+ nodeSizes: Map<string, NodeSize>,
396
+ stickySizes: Map<string, StickySize>,
397
+ startNodeUuid: string
398
+ ): Record<string, FlowPosition> {
399
+ const stickyPositions: Record<string, FlowPosition> = {};
400
+ const nodeUuids = Object.keys(newNodePositions);
401
+ if (nodeUuids.length === 0) return stickyPositions;
402
+
403
+ // For each sticky, find the closest node based on pre-reflow positions
404
+ const stickyToNode = new Map<string, string>();
405
+ const nodeStickies = new Map<string, { uuid: string; wasLeft: boolean }[]>();
406
+
407
+ for (const [stickyUuid, sticky] of Object.entries(stickies)) {
408
+ if (!sticky.position) continue;
409
+
410
+ const sx = sticky.position.left;
411
+ const sy = sticky.position.top;
412
+
413
+ let closestNode = nodeUuids[0];
414
+ let closestDist = Infinity;
415
+
416
+ for (const nodeUuid of nodeUuids) {
417
+ const np = oldNodePositions[nodeUuid];
418
+ if (!np) continue;
419
+ const dx = sx - np.left;
420
+ const dy = sy - np.top;
421
+ const dist = dx * dx + dy * dy;
422
+ if (dist < closestDist) {
423
+ closestDist = dist;
424
+ closestNode = nodeUuid;
425
+ }
426
+ }
427
+
428
+ stickyToNode.set(stickyUuid, closestNode);
429
+
430
+ // Was the sticky to the left of the node?
431
+ const nodePos = oldNodePositions[closestNode];
432
+ const wasLeft = nodePos ? sx < nodePos.left : false;
433
+
434
+ const list = nodeStickies.get(closestNode) || [];
435
+ list.push({ uuid: stickyUuid, wasLeft });
436
+ nodeStickies.set(closestNode, list);
437
+ }
438
+
439
+ // Place stickies next to their associated nodes
440
+ // Collect all placed rectangles (nodes + stickies) for collision avoidance
441
+ const placed: { left: number; top: number; width: number; height: number }[] =
442
+ [];
443
+
444
+ // Add all nodes to placed rectangles
445
+ for (const nodeUuid of nodeUuids) {
446
+ const pos = newNodePositions[nodeUuid];
447
+ const size = nodeSizes.get(nodeUuid) || { width: 200, height: 100 };
448
+ placed.push({
449
+ left: pos.left,
450
+ top: pos.top,
451
+ width: size.width,
452
+ height: size.height
453
+ });
454
+ }
455
+
456
+ for (const [nodeUuid, stickyList] of nodeStickies) {
457
+ const nodePos = newNodePositions[nodeUuid];
458
+ if (!nodePos) continue;
459
+ const nodeSize = nodeSizes.get(nodeUuid) || { width: 200, height: 100 };
460
+
461
+ for (const { uuid: stickyUuid, wasLeft } of stickyList) {
462
+ const stickySize = stickySizes.get(stickyUuid) || {
463
+ width: 182,
464
+ height: 100
465
+ };
466
+
467
+ // Determine placement side: right of node if it's the start node and sticky
468
+ // was to the left, otherwise prefer the side it was on originally
469
+ const placeRight = (nodeUuid === startNodeUuid && wasLeft) || !wasLeft;
470
+
471
+ let candidateLeft: number;
472
+ if (placeRight) {
473
+ candidateLeft = nodePos.left + nodeSize.width + STICKY_GAP;
474
+ } else {
475
+ candidateLeft = nodePos.left - stickySize.width - STICKY_GAP;
476
+ }
477
+ let candidateTop = nodePos.top;
478
+
479
+ // Snap and clamp
480
+ candidateLeft = snapToGrid(Math.max(0, candidateLeft));
481
+ candidateTop = snapToGrid(Math.max(0, candidateTop));
482
+
483
+ // Nudge down if colliding with any placed rectangle
484
+ let maxAttempts = 50;
485
+ while (
486
+ maxAttempts-- > 0 &&
487
+ collidesWithAny(
488
+ candidateLeft,
489
+ candidateTop,
490
+ stickySize.width,
491
+ stickySize.height,
492
+ placed
493
+ )
494
+ ) {
495
+ candidateTop = snapToGrid(candidateTop + STICKY_GAP);
496
+ }
497
+
498
+ stickyPositions[stickyUuid] = {
499
+ left: candidateLeft,
500
+ top: candidateTop
501
+ };
502
+
503
+ // Add this sticky to placed rectangles
504
+ placed.push({
505
+ left: candidateLeft,
506
+ top: candidateTop,
507
+ width: stickySize.width,
508
+ height: stickySize.height
509
+ });
510
+ }
511
+ }
512
+
513
+ return stickyPositions;
514
+ }
515
+
516
+ function collidesWithAny(
517
+ left: number,
518
+ top: number,
519
+ width: number,
520
+ height: number,
521
+ placed: { left: number; top: number; width: number; height: number }[]
522
+ ): boolean {
523
+ for (const r of placed) {
524
+ if (
525
+ left < r.left + r.width + STICKY_GAP &&
526
+ left + width + STICKY_GAP > r.left &&
527
+ top < r.top + r.height + STICKY_GAP &&
528
+ top + height + STICKY_GAP > r.top
529
+ ) {
530
+ return true;
531
+ }
532
+ }
533
+ return false;
534
+ }
package/src/flow/types.ts CHANGED
@@ -296,6 +296,7 @@ export interface GroupLayoutConfig {
296
296
  collapsible?: boolean;
297
297
  collapsed?: boolean | ((formData: FormData) => boolean); // initial state if collapsible - can be a function
298
298
  helpText?: string;
299
+ contentPadding?: string; // CSS padding for group content area
299
300
  getGroupValueCount?: (formData: FormData) => number; // optional function to get count for bubble display
300
301
  }
301
302
 
package/src/flow/utils.ts CHANGED
@@ -2,6 +2,21 @@ import { html } from 'lit-html';
2
2
  import { NamedObject, FlowPosition } from '../store/flow-definition';
3
3
  import { FlowIssue } from '../store/AppState';
4
4
 
5
+ const IS_MAC =
6
+ typeof navigator !== 'undefined' &&
7
+ /Mac|iPod|iPhone|iPad/.test(navigator.platform);
8
+
9
+ /**
10
+ * Returns true if the mouse event is a right-click or equivalent:
11
+ * - button !== 0 (actual right-click or middle-click on any platform)
12
+ * - ctrl+click on macOS (emulates right-click / context menu)
13
+ */
14
+ export function isRightClick(event: MouseEvent): boolean {
15
+ if (event.button !== 0) return true;
16
+ if (IS_MAC && event.ctrlKey) return true;
17
+ return false;
18
+ }
19
+
5
20
  export function formatIssueMessage(issue: FlowIssue): string {
6
21
  if (issue.dependency) {
7
22
  const name = issue.dependency.name || issue.dependency.key;
@@ -234,9 +249,10 @@ export const getNodeBounds = (
234
249
  return null;
235
250
  }
236
251
 
237
- const rect = nodeElement.getBoundingClientRect();
238
- const width = rect.width;
239
- const height = rect.height;
252
+ // Use offsetWidth/offsetHeight instead of getBoundingClientRect() so
253
+ // dimensions are in CSS-layout space and unaffected by ancestor transforms (zoom).
254
+ const width = nodeElement.offsetWidth;
255
+ const height = nodeElement.offsetHeight;
240
256
 
241
257
  return {
242
258
  uuid: nodeUuid,
@@ -523,6 +523,11 @@ export class Compose extends FieldElement {
523
523
  }
524
524
 
525
525
  public triggerSend() {
526
+ // Recompute empty synchronously since the Lit update cycle that normally
527
+ // calls checkIfEmpty() in updated() may not have flushed yet (currentText
528
+ // is set synchronously in handleChatboxChange, but empty is only updated
529
+ // in the async updated() callback).
530
+ this.checkIfEmpty();
526
531
  if (!this.empty) {
527
532
  this.fireCustomEvent(CustomEventType.Submitted, {
528
533
  langValues: this.langValues
@@ -357,9 +357,7 @@ export class FieldRenderer {
357
357
 
358
358
  return html`<div class="form-field">
359
359
  ${config.helpText
360
- ? html`<div
361
- style="color: #666; font-size: 13px; margin-bottom: 14px;"
362
- >
360
+ ? html`<div style="color: #666; font-size: 13px; margin-bottom: 14px;">
363
361
  ${config.helpText}
364
362
  </div>`
365
363
  : ''}
@@ -165,6 +165,8 @@ export class Dialog extends ResizeElement {
165
165
 
166
166
  temba-button {
167
167
  margin-left: 10px;
168
+ --button-y: 0.4em;
169
+ --button-x: 1em;
168
170
  }
169
171
 
170
172
  .dialog-body temba-loading {