@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.
- package/CHANGELOG.md +22 -0
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +859 -656
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/Icons.js +3 -1
- package/out-tsc/src/Icons.js.map +1 -1
- package/out-tsc/src/display/Button.js +2 -2
- package/out-tsc/src/display/Button.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +24 -1
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +7 -2
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +665 -67
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +8 -5
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +40 -28
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/actions/send_msg.js +2 -1
- package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
- package/out-tsc/src/flow/nodes/wait_for_response.js +1 -1
- package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
- package/out-tsc/src/flow/reflow.js +393 -0
- package/out-tsc/src/flow/reflow.js.map +1 -0
- package/out-tsc/src/flow/types.js.map +1 -1
- package/out-tsc/src/flow/utils.js +18 -3
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/form/Compose.js +5 -0
- package/out-tsc/src/form/Compose.js.map +1 -1
- package/out-tsc/src/form/FieldRenderer.js +1 -3
- package/out-tsc/src/form/FieldRenderer.js.map +1 -1
- package/out-tsc/src/layout/Dialog.js +2 -0
- package/out-tsc/src/layout/Dialog.js.map +1 -1
- package/out-tsc/src/list/SortableList.js +39 -19
- package/out-tsc/src/list/SortableList.js.map +1 -1
- package/out-tsc/src/live/ContactChat.js +10 -1
- package/out-tsc/src/live/ContactChat.js.map +1 -1
- package/out-tsc/src/version.js +9 -0
- package/out-tsc/src/version.js.map +1 -0
- package/out-tsc/test/temba-canvas-menu.test.js +44 -0
- package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
- package/out-tsc/test/temba-contact-chat.test.js +12 -0
- package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
- package/out-tsc/test/temba-flow-collision.test.js +25 -0
- package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
- package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
- package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
- package/out-tsc/test/temba-flow-editor.test.js +164 -1
- package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
- package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
- package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber.test.js +31 -0
- package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
- package/out-tsc/test/temba-flow-reflow.test.js +472 -0
- package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
- package/out-tsc/test/temba-sortable-list.test.js +93 -0
- package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
- package/package.json +1 -1
- package/rollup.components.mjs +7 -1
- package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
- package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
- package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
- package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
- package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
- package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
- package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
- package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
- package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
- package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
- package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
- package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
- package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
- package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
- package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
- package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
- package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
- package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
- package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
- package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
- package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
- package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
- package/screenshots/truth/list/fields-dragging.png +0 -0
- package/screenshots/truth/list/sortable-dragging.png +0 -0
- package/screenshots/truth/modax/simple.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
- package/src/Icons.ts +3 -1
- package/src/display/Button.ts +2 -2
- package/src/display/FloatingTab.ts +1 -1
- package/src/flow/CanvasMenu.ts +28 -3
- package/src/flow/CanvasNode.ts +7 -2
- package/src/flow/Editor.ts +769 -76
- package/src/flow/NodeEditor.ts +8 -4
- package/src/flow/Plumber.ts +65 -35
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/nodes/wait_for_response.ts +1 -1
- package/src/flow/reflow.ts +534 -0
- package/src/flow/types.ts +1 -0
- package/src/flow/utils.ts +19 -3
- package/src/form/Compose.ts +5 -0
- package/src/form/FieldRenderer.ts +1 -3
- package/src/layout/Dialog.ts +2 -0
- package/src/list/SortableList.ts +40 -19
- package/src/live/ContactChat.ts +10 -1
- package/src/store/flow-definition.d.ts +1 -0
- package/src/version.ts +10 -0
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/expand-06.svg +1 -0
- package/static/svg/work/used/expand-06.svg +3 -0
- package/test/temba-canvas-menu.test.ts +55 -0
- package/test/temba-contact-chat.test.ts +17 -0
- package/test/temba-flow-collision.test.ts +31 -0
- package/test/temba-flow-editor-zoom.test.ts +583 -0
- package/test/temba-flow-editor.test.ts +211 -1
- package/test/temba-flow-node-drag.test.ts +171 -0
- package/test/temba-flow-plumber.test.ts +38 -0
- package/test/temba-flow-reflow.test.ts +703 -0
- package/test/temba-sortable-list.test.ts +120 -0
- package/web-dev-server.config.mjs +5 -1
- package/web-test-runner.config.mjs +4 -1
- package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
- package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
- package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
- package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
- package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
- package/screenshots/truth/compose/attachments-with-failures.png +0 -0
- package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
- package/screenshots/truth/contacts/tickets-assignment.png +0 -0
- package/screenshots/truth/contacts/tickets.png +0 -0
- package/screenshots/truth/flow/editor-basic.png +0 -0
- package/screenshots/truth/formfield/markdown-errors.png +0 -0
- package/screenshots/truth/formfield/no-errors.png +0 -0
- package/screenshots/truth/formfield/plain-text-errors.png +0 -0
- package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
- package/screenshots/truth/omnibox/selected.png +0 -0
- package/screenshots/truth/select/enabled-multi-selection.png +0 -0
- package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
- package/screenshots/truth/select/endpoint-initial-value.png +0 -0
- package/screenshots/truth/select/initial-value.png +0 -0
- package/screenshots/truth/select/multi-reorder-final.png +0 -0
- package/screenshots/truth/select/multi-reorder-initial.png +0 -0
- package/screenshots/truth/select/selected-multi-test.png +0 -0
- package/screenshots/truth/select/value-initial.png +0 -0
- package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
- package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
- package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
- 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
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
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,
|
package/src/form/Compose.ts
CHANGED
|
@@ -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
|
: ''}
|