@bpmn-nova/studio 0.3.0-preview

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.
@@ -0,0 +1,229 @@
1
+ import {
2
+ NODE_DEFINITIONS,
3
+ autoLayout,
4
+ elementScopeId,
5
+ getNode,
6
+ } from '@bpmn-nova/core';
7
+
8
+ const LAYOUT_KINDS = new Set(['event', 'task', 'container', 'gateway']);
9
+ const GROUPABLE_KINDS = new Set(['event', 'task', 'container', 'gateway', 'data', 'dataStore', 'annotation']);
10
+ const RESIZABLE_KINDS = new Set(['group', 'participant', 'lane']);
11
+
12
+ function nodeKind(node) {
13
+ return (NODE_DEFINITIONS[node?.type] || NODE_DEFINITIONS.generic).kind;
14
+ }
15
+
16
+ export function isMarqueeSelectableNode(node) {
17
+ return isGroupableNode(node);
18
+ }
19
+
20
+ export function isGroupableNode(node) {
21
+ return Boolean(node && GROUPABLE_KINDS.has(nodeKind(node)));
22
+ }
23
+
24
+ export function isResizableNode(node) {
25
+ return Boolean(node && RESIZABLE_KINDS.has(nodeKind(node)) && !(nodeKind(node) === 'lane' && node.containerId));
26
+ }
27
+
28
+ export function isSelectionLayoutNode(node) {
29
+ return Boolean(node && LAYOUT_KINDS.has(nodeKind(node)));
30
+ }
31
+
32
+ export function normalizeRect(rect = {}) {
33
+ const x1 = Number(rect.x) || 0;
34
+ const y1 = Number(rect.y) || 0;
35
+ const x2 = x1 + (Number(rect.width) || 0);
36
+ const y2 = y1 + (Number(rect.height) || 0);
37
+ return {
38
+ x: Math.min(x1, x2),
39
+ y: Math.min(y1, y2),
40
+ width: Math.abs(x2 - x1),
41
+ height: Math.abs(y2 - y1),
42
+ };
43
+ }
44
+
45
+ function intersects(node, rect) {
46
+ return node.x < rect.x + rect.width
47
+ && node.x + node.width > rect.x
48
+ && node.y < rect.y + rect.height
49
+ && node.y + node.height > rect.y;
50
+ }
51
+
52
+ export function nodesInSelectionRect(model, scopeId, rawRect) {
53
+ const rect = normalizeRect(rawRect);
54
+ return model.nodes.filter((node) => (
55
+ elementScopeId(model, node) === scopeId
56
+ && isMarqueeSelectableNode(node)
57
+ && intersects(node, rect)
58
+ ));
59
+ }
60
+
61
+ export function selectedDiagramNodes(model, selection, scopeId) {
62
+ const refs = selection?.kind === 'multi'
63
+ ? selection.items
64
+ : selection?.kind === 'node'
65
+ ? [selection]
66
+ : [];
67
+ const seen = new Set();
68
+ const nodes = [];
69
+ for (const ref of refs) {
70
+ if (ref.kind !== 'node' || seen.has(ref.id)) continue;
71
+ const node = getNode(model, ref.id);
72
+ if (!node || elementScopeId(model, node) !== scopeId) continue;
73
+ seen.add(node.id);
74
+ nodes.push(node);
75
+ }
76
+ return nodes;
77
+ }
78
+
79
+ export function selectedGroupableNodes(model, selection, scopeId) {
80
+ return selectedDiagramNodes(model, selection, scopeId).filter(isGroupableNode);
81
+ }
82
+
83
+ export function selectedLayoutNodes(model, selection, scopeId) {
84
+ return selectedDiagramNodes(model, selection, scopeId).filter(isSelectionLayoutNode);
85
+ }
86
+
87
+ export function selectionBounds(nodes = []) {
88
+ if (!nodes.length) return null;
89
+ const left = Math.min(...nodes.map((node) => node.x));
90
+ const top = Math.min(...nodes.map((node) => node.y));
91
+ const right = Math.max(...nodes.map((node) => node.x + node.width));
92
+ const bottom = Math.max(...nodes.map((node) => node.y + node.height));
93
+ return { x: left, y: top, width: right - left, height: bottom - top };
94
+ }
95
+
96
+ export function mergeSelectionRefs(currentNodes, hitNodes, mode = 'replace') {
97
+ if (mode === 'replace') return hitNodes.map((node) => ({ kind: 'node', id: node.id }));
98
+ const order = currentNodes.map((node) => node.id);
99
+ const ids = new Set(order);
100
+ for (const node of hitNodes) {
101
+ if (mode === 'toggle' && ids.has(node.id)) {
102
+ ids.delete(node.id);
103
+ order.splice(order.indexOf(node.id), 1);
104
+ } else if (!ids.has(node.id)) {
105
+ ids.add(node.id);
106
+ order.push(node.id);
107
+ }
108
+ }
109
+ return order.map((id) => ({ kind: 'node', id }));
110
+ }
111
+
112
+ function incidentEdges(model, nodeIds) {
113
+ return model.edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target));
114
+ }
115
+
116
+ function alignNodes(nodes, alignment) {
117
+ const bounds = selectionBounds(nodes);
118
+ for (const node of nodes) {
119
+ if (alignment === 'left') node.x = bounds.x;
120
+ else if (alignment === 'centerX') node.x = Math.round(bounds.x + (bounds.width - node.width) / 2);
121
+ else if (alignment === 'right') node.x = bounds.x + bounds.width - node.width;
122
+ else if (alignment === 'top') node.y = bounds.y;
123
+ else if (alignment === 'centerY') node.y = Math.round(bounds.y + (bounds.height - node.height) / 2);
124
+ else if (alignment === 'bottom') node.y = bounds.y + bounds.height - node.height;
125
+ }
126
+ }
127
+
128
+ function distributeNodes(nodes, axis) {
129
+ const horizontal = axis === 'horizontal';
130
+ const ordered = [...nodes].sort((a, b) => (horizontal ? a.x - b.x : a.y - b.y));
131
+ const first = ordered[0];
132
+ const last = ordered[ordered.length - 1];
133
+ const start = horizontal ? first.x : first.y;
134
+ const end = horizontal ? last.x + last.width : last.y + last.height;
135
+ const totalSize = ordered.reduce((sum, node) => sum + (horizontal ? node.width : node.height), 0);
136
+ const gap = Math.max(0, (end - start - totalSize) / Math.max(1, ordered.length - 1));
137
+ let cursor = start;
138
+ for (const node of ordered) {
139
+ if (horizontal) node.x = Math.round(cursor);
140
+ else node.y = Math.round(cursor);
141
+ cursor += (horizontal ? node.width : node.height) + gap;
142
+ }
143
+ }
144
+
145
+ function overlaps(a, b, margin = 16) {
146
+ return a.x < b.x + b.width + margin
147
+ && a.x + a.width + margin > b.x
148
+ && a.y < b.y + b.height + margin
149
+ && a.y + a.height + margin > b.y;
150
+ }
151
+
152
+ function collisionOffset(model, scopeId, selectedIds, selectedNodes) {
153
+ const blockers = model.nodes.filter((node) => (
154
+ elementScopeId(model, node) === scopeId
155
+ && isSelectionLayoutNode(node)
156
+ && !selectedIds.has(node.id)
157
+ ));
158
+ const clear = (dx, dy) => !selectedNodes.some((selected) => blockers.some((node) => overlaps(
159
+ { x: selected.x + dx, y: selected.y + dy, width: selected.width, height: selected.height },
160
+ node,
161
+ )));
162
+ if (clear(0, 0)) return { x: 0, y: 0 };
163
+ const step = Math.max(16, model.settings?.gridSize || 16);
164
+ for (let ring = 1; ring <= 80; ring += 1) {
165
+ const candidates = [];
166
+ for (let x = -ring; x <= ring; x += 1) {
167
+ candidates.push({ x: x * step, y: -ring * step }, { x: x * step, y: ring * step });
168
+ }
169
+ for (let y = -ring + 1; y < ring; y += 1) {
170
+ candidates.push({ x: -ring * step, y: y * step }, { x: ring * step, y: y * step });
171
+ }
172
+ candidates.sort((a, b) => a.x * a.x + a.y * a.y - (b.x * b.x + b.y * b.y));
173
+ const match = candidates.find((candidate) => clear(candidate.x, candidate.y));
174
+ if (match) return match;
175
+ }
176
+ return { x: 0, y: 0 };
177
+ }
178
+
179
+ function moveAttachedBoundaryEvents(model, scopeId, originalById) {
180
+ for (const boundary of model.nodes) {
181
+ const definition = NODE_DEFINITIONS[boundary.type] || NODE_DEFINITIONS.generic;
182
+ if (definition.kind !== 'boundary' || elementScopeId(model, boundary) !== scopeId) continue;
183
+ const hostId = boundary.properties?.attachedToRef;
184
+ const original = originalById.get(hostId);
185
+ const host = original && getNode(model, hostId);
186
+ if (!host) continue;
187
+ boundary.x = Math.round(boundary.x + host.x - original.x);
188
+ boundary.y = Math.round(boundary.y + host.y - original.y);
189
+ }
190
+ }
191
+
192
+ export function applySelectionArrangement(model, selection, scopeId, operation = {}) {
193
+ const nodes = selectedLayoutNodes(model, selection, scopeId);
194
+ const minimum = operation.type === 'reroute' ? 1 : 2;
195
+ if (nodes.length < minimum) return false;
196
+ if (operation.type === 'distribute' && nodes.length < 3) return false;
197
+ const selectedIds = new Set(nodes.map((node) => node.id));
198
+ const originalById = new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
199
+
200
+ const alignments = new Set(['left', 'centerX', 'right', 'top', 'centerY', 'bottom']);
201
+ if (operation.type === 'align' && alignments.has(operation.alignment)) alignNodes(nodes, operation.alignment);
202
+ else if (operation.type === 'distribute' && ['horizontal', 'vertical'].includes(operation.axis)) distributeNodes(nodes, operation.axis);
203
+ else if (operation.type === 'layout') {
204
+ const previousBounds = selectionBounds(nodes);
205
+ const graph = {
206
+ id: model.id,
207
+ engine: model.engine,
208
+ settings: { ...(model.settings || {}) },
209
+ nodes,
210
+ edges: model.edges.filter((edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target)),
211
+ };
212
+ autoLayout(graph, {
213
+ direction: model.settings?.direction || 'horizontal',
214
+ density: model.settings?.layoutDensity || 'balanced',
215
+ startX: 0,
216
+ startY: 0,
217
+ });
218
+ const nextBounds = selectionBounds(nodes);
219
+ const dx = Math.round(previousBounds.x + previousBounds.width / 2 - nextBounds.x - nextBounds.width / 2);
220
+ const dy = Math.round(previousBounds.y + previousBounds.height / 2 - nextBounds.y - nextBounds.height / 2);
221
+ for (const node of nodes) { node.x += dx; node.y += dy; }
222
+ const collision = collisionOffset(model, scopeId, selectedIds, nodes);
223
+ for (const node of nodes) { node.x += collision.x; node.y += collision.y; }
224
+ } else if (operation.type !== 'reroute') return false;
225
+
226
+ for (const edge of incidentEdges(model, selectedIds)) edge.waypoints = null;
227
+ moveAttachedBoundaryEvents(model, scopeId, originalById);
228
+ return true;
229
+ }