@pauloricca/spagbol 0.0.0-stage → 0.1.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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1248 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +171 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/nodeDrag.d.ts +7 -0
  114. package/dist/react/nodeDrag.d.ts.map +1 -0
  115. package/dist/react/nodeDrag.js +27 -0
  116. package/dist/react/nodeDrag.js.map +1 -0
  117. package/dist/react/portState.d.ts +10 -0
  118. package/dist/react/portState.d.ts.map +1 -0
  119. package/dist/react/portState.js +35 -0
  120. package/dist/react/portState.js.map +1 -0
  121. package/dist/react/viewport.d.ts +24 -0
  122. package/dist/react/viewport.d.ts.map +1 -0
  123. package/dist/react/viewport.js +55 -0
  124. package/dist/react/viewport.js.map +1 -0
  125. package/dist/styles.css +748 -0
  126. package/package.json +58 -4
  127. package/src/core/controller.ts +844 -0
  128. package/src/core/graph.ts +451 -0
  129. package/src/core/grouping.ts +159 -0
  130. package/src/core/groups.ts +125 -0
  131. package/src/core/index.ts +7 -0
  132. package/src/core/model.ts +304 -0
  133. package/src/core/observations.ts +31 -0
  134. package/src/core/serialization.ts +184 -0
  135. package/src/index.ts +9 -0
  136. package/src/react/Controls.tsx +355 -0
  137. package/src/react/SpagbolCanvas.tsx +1893 -0
  138. package/src/react/SpagbolCanvasControls.tsx +255 -0
  139. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  140. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  141. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  142. package/src/react/SpagbolControlNumber.tsx +297 -0
  143. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  144. package/src/react/SpagbolControlZoom.tsx +102 -0
  145. package/src/react/SpagbolNodeFrame.tsx +330 -0
  146. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  147. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  148. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  149. package/src/react/context.tsx +146 -0
  150. package/src/react/controls.css +122 -0
  151. package/src/react/duplicateDrag.ts +97 -0
  152. package/src/react/flowPresentation.ts +56 -0
  153. package/src/react/geometry.ts +53 -0
  154. package/src/react/index.ts +61 -0
  155. package/src/react/link-controls.css +123 -0
  156. package/src/react/nodeDrag.ts +33 -0
  157. package/src/react/portState.ts +24 -0
  158. package/src/react/styles.css +748 -0
  159. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,732 @@
1
+ import { createEditablePort, resolveNewBoundaryPorts, spagbolNewPortId, createGroupPort, findGroupControl, groupControlKey, isGroupBoundary, normalizeGroupPortLabel, synchronizeGroupBoundaries } from "./groups.js";
2
+ import { clone, createId, emptyGraph, emptySelection, spagbolGroupKinds, graphAt, portsFor, validPath, LinkModeRegistry, NodeRegistry, } from "./model.js";
3
+ import { areaMembers, connectionError, copyFragment, pasteFragment, pruneInvalidPorts, removeLinks, removeNodes, synchronizeSharedGraph, validateGraph, } from "./graph.js";
4
+ import { assertDocument } from "./serialization.js";
5
+ import { groupNodes } from "./grouping.js";
6
+ function freeze(value) {
7
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
8
+ for (const child of Object.values(value))
9
+ freeze(child);
10
+ Object.freeze(value);
11
+ }
12
+ return value;
13
+ }
14
+ /** Retain immutable identities for unchanged document branches after an atomic edit. */
15
+ function shareUnchanged(before, after) {
16
+ if (Object.is(before, after))
17
+ return before;
18
+ if (after && typeof after === "object" && Object.isFrozen(after))
19
+ return after;
20
+ if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after))
21
+ return after;
22
+ const previous = before, next = after;
23
+ const keys = Object.keys(next);
24
+ let equal = keys.length === Object.keys(previous).length;
25
+ for (const key of keys) {
26
+ if (!(key in previous)) {
27
+ equal = false;
28
+ continue;
29
+ }
30
+ next[key] = shareUnchanged(previous[key], next[key]);
31
+ if (!Object.is(previous[key], next[key]))
32
+ equal = false;
33
+ }
34
+ return equal ? before : after;
35
+ }
36
+ /** One controller per document. Nothing is stored in module-level mutable state. */
37
+ export class GraphEditor {
38
+ nodes;
39
+ modes;
40
+ policy;
41
+ #options;
42
+ #listeners = new Set();
43
+ #commits = new Set();
44
+ #snapshot;
45
+ #past = [];
46
+ #future = [];
47
+ #gesture = null;
48
+ #clipboard = null;
49
+ constructor(options) {
50
+ this.#options = options;
51
+ this.nodes = options.nodes;
52
+ this.modes = options.modes ?? new LinkModeRegistry();
53
+ this.policy = { linkProperties: ['weight'], ...options.policy };
54
+ const source = options.document ?? emptyGraph();
55
+ assertDocument(source);
56
+ const document = clone(source);
57
+ this.#snapshot = freeze({
58
+ document,
59
+ path: [],
60
+ selection: emptySelection(),
61
+ canUndo: false,
62
+ canRedo: false,
63
+ gesture: null,
64
+ mode: "edit",
65
+ });
66
+ }
67
+ getSnapshot = () => this.#snapshot;
68
+ subscribe = (listener) => {
69
+ this.#listeners.add(listener);
70
+ return () => {
71
+ this.#listeners.delete(listener);
72
+ };
73
+ };
74
+ onCommit(listener) {
75
+ this.#commits.add(listener);
76
+ return () => {
77
+ this.#commits.delete(listener);
78
+ };
79
+ }
80
+ get graph() {
81
+ return graphAt(this.#snapshot.document, this.#snapshot.path);
82
+ }
83
+ get editing() {
84
+ return this.#snapshot.mode === "edit";
85
+ }
86
+ diagnostics() {
87
+ return validateGraph(this.#snapshot.document, this.nodes, this.modes, this.policy);
88
+ }
89
+ #publish(patch = {}) {
90
+ const document = patch.document ?? this.#snapshot.document;
91
+ const path = shareUnchanged(this.#snapshot.path, validPath(document, patch.path ?? this.#snapshot.path));
92
+ const graph = graphAt(document, path);
93
+ const selection = patch.selection ?? this.#snapshot.selection;
94
+ this.#snapshot = freeze({
95
+ ...this.#snapshot,
96
+ ...patch,
97
+ document,
98
+ path,
99
+ selection: shareUnchanged(this.#snapshot.selection, {
100
+ port: selection.port && graph.nodes.some(node => node.id === selection.port.nodeId && portsFor(node, this.nodes).some(port => port.id === selection.port.portId && port.direction === selection.port.direction)) ? selection.port : undefined,
101
+ nodeIds: selection.nodeIds.filter((id) => graph.nodes.some((node) => node.id === id)),
102
+ linkIds: selection.linkIds.filter((id) => graph.links.some((link) => link.id === id)),
103
+ areaIds: selection.areaIds.filter((id) => graph.areas.some((area) => area.id === id)),
104
+ }),
105
+ canUndo: this.#past.length > 0,
106
+ canRedo: this.#future.length > 0,
107
+ gesture: this.#gesture?.label ?? null,
108
+ });
109
+ for (const listener of this.#listeners)
110
+ listener();
111
+ }
112
+ #event(entry, origin) {
113
+ return {
114
+ label: entry.label,
115
+ kinds: [...entry.kinds],
116
+ path: [...entry.path],
117
+ document: this.#snapshot.document,
118
+ origin,
119
+ };
120
+ }
121
+ #emit(event) {
122
+ this.#options.onCommit?.(event);
123
+ for (const listener of this.#commits)
124
+ listener(event);
125
+ }
126
+ #remember(entry) {
127
+ this.#past = [...this.#past, entry].slice(-Math.max(1, this.#options.historyLimit ?? 100));
128
+ this.#future = [];
129
+ }
130
+ /** App transformations use this same atomic path as built-in commands. */
131
+ edit(label, kinds, transform) {
132
+ if (this.#snapshot.mode === "readonly")
133
+ return;
134
+ const changes = typeof kinds === "string" ? [kinds] : [...kinds];
135
+ if (!this.editing && changes.some((kind) => kind !== "values"))
136
+ return;
137
+ const before = this.#snapshot.document;
138
+ let after = clone(before);
139
+ transform(graphAt(after, this.#snapshot.path));
140
+ this.#options.normalizeDocument?.(after, before);
141
+ synchronizeSharedGraph(after, this.#snapshot.path);
142
+ synchronizeGroupBoundaries(after, this.nodes);
143
+ assertDocument(after);
144
+ after = shareUnchanged(before, after);
145
+ if (before === after)
146
+ return;
147
+ const entry = {
148
+ before,
149
+ after,
150
+ label,
151
+ kinds: changes,
152
+ path: [...this.#snapshot.path],
153
+ };
154
+ if (this.#gesture)
155
+ for (const kind of changes)
156
+ this.#gesture.kinds.add(kind);
157
+ else
158
+ this.#remember(entry);
159
+ this.#publish({ document: after });
160
+ if (this.#gesture)
161
+ this.#options.onPreview?.(this.#event(entry, "edit"));
162
+ else
163
+ this.#emit(this.#event(entry, "edit"));
164
+ }
165
+ beginGesture(label) {
166
+ if (this.#gesture)
167
+ throw new Error("Finish or cancel the current gesture first.");
168
+ this.#gesture = {
169
+ before: this.#snapshot.document,
170
+ label,
171
+ kinds: new Set(),
172
+ path: [...this.#snapshot.path],
173
+ };
174
+ this.#publish();
175
+ }
176
+ commitGesture() {
177
+ const gesture = this.#gesture;
178
+ if (!gesture)
179
+ return;
180
+ this.#gesture = null;
181
+ const entry = {
182
+ ...gesture,
183
+ after: this.#snapshot.document,
184
+ kinds: [...gesture.kinds],
185
+ };
186
+ const changed = JSON.stringify(entry.before) !== JSON.stringify(entry.after);
187
+ if (changed)
188
+ this.#remember(entry);
189
+ this.#publish();
190
+ this.#options.onPreview?.(null);
191
+ if (changed)
192
+ this.#emit(this.#event(entry, "edit"));
193
+ }
194
+ cancelGesture() {
195
+ const gesture = this.#gesture;
196
+ if (!gesture)
197
+ return;
198
+ this.#gesture = null;
199
+ this.#publish({ document: gesture.before, path: gesture.path });
200
+ this.#options.onPreview?.(null);
201
+ }
202
+ setMode(mode) {
203
+ this.cancelGesture();
204
+ this.#publish({ mode, selection: emptySelection() });
205
+ }
206
+ select(selection) {
207
+ const next = shareUnchanged(this.#snapshot.selection, { ...emptySelection(), port: undefined, ...selection });
208
+ if (next !== this.#snapshot.selection)
209
+ this.#publish({ selection: next });
210
+ }
211
+ enter(nodeId) {
212
+ if (!this.graph.nodes.find((node) => node.id === nodeId)?.graph)
213
+ return;
214
+ this.commitGesture();
215
+ this.#publish({
216
+ path: [...this.#snapshot.path, nodeId],
217
+ selection: emptySelection(),
218
+ });
219
+ }
220
+ navigate(path) {
221
+ this.commitGesture();
222
+ this.#publish({ path, selection: emptySelection() });
223
+ }
224
+ exit() {
225
+ this.navigate(this.#snapshot.path.slice(0, -1));
226
+ }
227
+ undo() {
228
+ if (!this.editing)
229
+ return;
230
+ this.cancelGesture();
231
+ const entry = this.#past.pop();
232
+ if (!entry)
233
+ return;
234
+ this.#future.push(entry);
235
+ this.#publish({
236
+ document: entry.before,
237
+ path: entry.path,
238
+ selection: emptySelection(),
239
+ });
240
+ this.#emit(this.#event(entry, "undo"));
241
+ }
242
+ redo() {
243
+ if (!this.editing)
244
+ return;
245
+ this.cancelGesture();
246
+ const entry = this.#future.pop();
247
+ if (!entry)
248
+ return;
249
+ this.#past.push(entry);
250
+ this.#publish({
251
+ document: entry.after,
252
+ path: entry.path,
253
+ selection: emptySelection(),
254
+ });
255
+ this.#emit(this.#event(entry, "redo"));
256
+ }
257
+ replace(document) {
258
+ if (!this.editing)
259
+ return;
260
+ assertDocument(document);
261
+ this.cancelGesture();
262
+ this.#past = [];
263
+ this.#future = [];
264
+ this.#publish({
265
+ document: clone(document),
266
+ path: [],
267
+ selection: emptySelection(),
268
+ });
269
+ this.#emit({
270
+ label: "Load graph",
271
+ kinds: ["structure", "values", "layout", "metadata"],
272
+ path: [],
273
+ document: this.#snapshot.document,
274
+ origin: "replace",
275
+ });
276
+ }
277
+ addNode(kind, position) {
278
+ if (!this.editing)
279
+ return;
280
+ const node = this.nodes.create(kind, position);
281
+ this.edit("Add node", "structure", (graph) => {
282
+ graph.nodes.push(node);
283
+ });
284
+ this.select({ nodeIds: [node.id] });
285
+ return node.id;
286
+ }
287
+ updateNode(id, update, kind = "metadata") {
288
+ this.edit("Edit node", kind, (graph) => {
289
+ const node = graph.nodes.find((node) => node.id === id);
290
+ if (!node)
291
+ return;
292
+ Object.assign(node, clone(update));
293
+ if (kind === "structure")
294
+ pruneInvalidPorts(graph, this.nodes, id);
295
+ });
296
+ }
297
+ changeNodeKind(id, kind, data = {}) {
298
+ if (!this.editing)
299
+ return;
300
+ const previous = this.graph.nodes.find(node => node.id === id);
301
+ if (!previous || previous.kind === kind)
302
+ return;
303
+ const replacement = this.nodes.create(kind, previous.position, id);
304
+ Object.assign(replacement.data, clone(data));
305
+ const oldPorts = portsFor(previous, this.nodes);
306
+ const newPorts = portsFor(replacement, this.nodes);
307
+ if (previous.enabled !== undefined)
308
+ replacement.enabled = previous.enabled;
309
+ if (previous.compact !== undefined)
310
+ replacement.compact = previous.compact;
311
+ if (previous.compactLabel !== undefined)
312
+ replacement.compactLabel = previous.compactLabel;
313
+ if (previous.scale !== undefined)
314
+ replacement.scale = previous.scale;
315
+ if (!replacement.size && previous.size)
316
+ replacement.size = { ...previous.size };
317
+ for (const port of portsFor(replacement, this.nodes))
318
+ if (port.direction === 'input' && previous.values[port.id] !== undefined)
319
+ replacement.values[port.id] = port.integer
320
+ ? Math.round(Math.max(port.min ?? -Infinity, Math.min(port.max ?? Infinity, previous.values[port.id])))
321
+ : Math.max(port.min ?? -Infinity, Math.min(port.max ?? Infinity, previous.values[port.id]));
322
+ this.edit('Change node type', 'structure', graph => {
323
+ graph.nodes = graph.nodes.map(node => node.id === id ? replacement : node);
324
+ for (const link of graph.links) {
325
+ for (const direction of ['input', 'output']) {
326
+ const endpoint = direction === 'output' ? link.source : link.target.type === 'port' ? link.target : undefined;
327
+ if (!endpoint || endpoint.nodeId !== id)
328
+ continue;
329
+ const before = oldPorts.filter(port => port.direction === direction);
330
+ const after = newPorts.filter(port => port.direction === direction);
331
+ const next = after[Math.max(0, before.findIndex(port => port.id === endpoint.portId))];
332
+ endpoint.portId = next?.id ?? '__spagbol_removed_port__';
333
+ }
334
+ }
335
+ pruneInvalidPorts(graph, this.nodes, id);
336
+ });
337
+ }
338
+ /** Scale selected nodes around their shared visual center in one undo entry. */
339
+ scaleSelection(factor, measuredSizes = new Map()) {
340
+ if (!this.editing || !Number.isFinite(factor) || factor <= 0 || factor === 1)
341
+ return;
342
+ const selected = this.graph.nodes.filter(node => this.getSnapshot().selection.nodeIds.includes(node.id));
343
+ if (!selected.length)
344
+ return;
345
+ const bounds = selected.map(node => ({ node, size: measuredSizes.get(node.id) ?? {
346
+ width: (node.size?.width ?? 180) * (node.scale ?? 1),
347
+ height: (node.size?.height ?? 100) * (node.scale ?? 1),
348
+ } }));
349
+ const center = {
350
+ x: (Math.min(...bounds.map(({ node }) => node.position.x)) + Math.max(...bounds.map(({ node, size }) => node.position.x + size.width))) / 2,
351
+ y: (Math.min(...bounds.map(({ node }) => node.position.y)) + Math.max(...bounds.map(({ node, size }) => node.position.y + size.height))) / 2,
352
+ };
353
+ const ids = new Set(selected.map(node => node.id));
354
+ this.edit('Scale selection', 'layout', graph => {
355
+ for (const node of graph.nodes)
356
+ if (ids.has(node.id)) {
357
+ node.position = { x: center.x + (node.position.x - center.x) * factor, y: center.y + (node.position.y - center.y) * factor };
358
+ node.scale = (node.scale ?? 1) * factor;
359
+ }
360
+ });
361
+ }
362
+ setValue(id, portId, value) {
363
+ this.setValues(id, { [portId]: value });
364
+ }
365
+ /** Several control values are one edit, with no intermediate document states. */
366
+ setValues(id, values) {
367
+ const current = this.graph.nodes.find(node => node.id === id);
368
+ if (!current)
369
+ return;
370
+ const ports = portsFor(current, this.nodes);
371
+ const changes = {};
372
+ for (const [portId, value] of Object.entries(values)) {
373
+ if (!Number.isFinite(value))
374
+ continue;
375
+ const port = ports.find(port => port.id === portId);
376
+ const bounded = Math.min(port?.max ?? Infinity, Math.max(port?.min ?? -Infinity, value));
377
+ const next = port?.integer ? Math.round(bounded) : bounded;
378
+ if (current.values[portId] !== next)
379
+ changes[portId] = next;
380
+ }
381
+ if (!Object.keys(changes).length)
382
+ return;
383
+ this.edit('Change values', 'values', graph => {
384
+ const node = graph.nodes.find(node => node.id === id);
385
+ Object.assign(node.values, changes);
386
+ if (node.kind === spagbolGroupKinds.inputs)
387
+ for (const port of node.ports ?? [])
388
+ if (changes[port.id] !== undefined)
389
+ port.defaultValue = changes[port.id];
390
+ });
391
+ }
392
+ setOverride(groupId, nodeId, portId, value) {
393
+ const key = groupControlKey(nodeId);
394
+ if (!Number.isFinite(value))
395
+ return;
396
+ this.edit("Change instance control", "values", (graph) => {
397
+ const group = graph.nodes.find((node) => node.id === groupId);
398
+ const node = group && findGroupControl(group, nodeId);
399
+ if (!group || !node)
400
+ return;
401
+ const port = portsFor(node, this.nodes).find((port) => port.id === portId);
402
+ const bounded = Math.max(port?.min ?? -Infinity, Math.min(port?.max ?? Infinity, value));
403
+ group.overrides ??= {};
404
+ group.overrides[key] ??= {};
405
+ group.overrides[key][portId] = port?.integer
406
+ ? Math.round(bounded)
407
+ : bounded;
408
+ });
409
+ }
410
+ resetOverride(groupId, nodeId) {
411
+ const key = groupControlKey(nodeId);
412
+ this.edit("Reset instance control", "values", (graph) => {
413
+ const group = graph.nodes.find((node) => node.id === groupId);
414
+ if (group?.overrides)
415
+ delete group.overrides[key];
416
+ if (group?.dataOverrides)
417
+ delete group.dataOverrides[key];
418
+ });
419
+ }
420
+ addBoundaryPort(nodeId, label, defaultValue = 0) {
421
+ this.edit('Add group port', 'structure', graph => {
422
+ const node = graph.nodes.find(item => item.id === nodeId);
423
+ if (node)
424
+ createEditablePort(node, this.nodes, label, defaultValue);
425
+ });
426
+ }
427
+ renameBoundaryPort(nodeId, portId, label) {
428
+ const normalized = normalizeGroupPortLabel(label);
429
+ this.edit('Rename group port', 'structure', graph => {
430
+ const node = graph.nodes.find(item => item.id === nodeId);
431
+ if (!node || !isGroupBoundary(node))
432
+ return;
433
+ if (node.ports?.some(port => port.id !== portId && port.label === normalized))
434
+ throw new Error('That port name already exists.');
435
+ const port = node.ports?.find(item => item.id === portId);
436
+ if (port)
437
+ port.label = normalized;
438
+ });
439
+ }
440
+ removeBoundaryPort(nodeId, portId) {
441
+ this.edit('Remove group port', 'structure', graph => {
442
+ const node = graph.nodes.find(item => item.id === nodeId);
443
+ if (!node || !isGroupBoundary(node))
444
+ return;
445
+ node.ports = node.ports?.filter(port => port.id !== portId);
446
+ delete node.values[portId];
447
+ pruneInvalidPorts(graph, this.nodes, nodeId);
448
+ });
449
+ }
450
+ moveBoundaryPort(nodeId, portId, index) {
451
+ this.edit('Reorder group port', 'structure', graph => {
452
+ const node = graph.nodes.find(item => item.id === nodeId);
453
+ if (!node || !isGroupBoundary(node) || !node.ports)
454
+ return;
455
+ const previous = node.ports.findIndex(port => port.id === portId);
456
+ if (previous < 0)
457
+ return;
458
+ const [port] = node.ports.splice(previous, 1);
459
+ node.ports.splice(Math.max(0, Math.min(node.ports.length, index)), 0, port);
460
+ });
461
+ }
462
+ exposeSelection(exposed = true) {
463
+ this.edit('Expose group controls', 'metadata', graph => {
464
+ const current = Array.isArray(graph.data?.exposedNodeIds) ? graph.data.exposedNodeIds.filter((id) => typeof id === 'string') : [];
465
+ const ids = new Set(current);
466
+ for (const id of this.#snapshot.selection.nodeIds) {
467
+ const node = graph.nodes.find(item => item.id === id);
468
+ if (!node || isGroupBoundary(node))
469
+ continue;
470
+ if (exposed)
471
+ ids.add(id);
472
+ else
473
+ ids.delete(id);
474
+ }
475
+ graph.data ??= {};
476
+ graph.data.exposedNodeIds = [...ids];
477
+ });
478
+ }
479
+ setDataOverride(groupId, nodeId, update) {
480
+ const key = groupControlKey(nodeId);
481
+ this.edit('Change instance control', 'values', graph => {
482
+ const group = graph.nodes.find(node => node.id === groupId);
483
+ if (!group || !findGroupControl(group, nodeId))
484
+ return;
485
+ group.dataOverrides ??= {};
486
+ group.dataOverrides[key] = { ...group.dataOverrides[key], ...update };
487
+ });
488
+ }
489
+ canConnect(source, target, mode, replacingId) {
490
+ const graph = source.portId === spagbolNewPortId || (target.type === 'port' && target.portId === spagbolNewPortId) ? clone(this.graph) : this.graph;
491
+ const prepared = graph === this.graph ? { source, target } : resolveNewBoundaryPorts(graph, source, target, this.nodes);
492
+ return connectionError(graph, this.nodes, this.modes, this.policy, prepared.source, prepared.target, mode, replacingId);
493
+ }
494
+ connect(source, target, mode = "set", settings = {}) {
495
+ if (!this.editing)
496
+ return;
497
+ const error = this.canConnect(source, target, mode);
498
+ if (error)
499
+ throw new Error(error);
500
+ const id = createId("link");
501
+ this.edit("Connect ports", "structure", (graph) => {
502
+ const prepared = resolveNewBoundaryPorts(graph, source, target, this.nodes);
503
+ graph.links.push({
504
+ id,
505
+ source: prepared.source,
506
+ target: prepared.target,
507
+ mode,
508
+ weight: 1,
509
+ ...settings,
510
+ });
511
+ });
512
+ this.select({ linkIds: [id] });
513
+ return id;
514
+ }
515
+ reconnect(id, source, target, duplicate = false) {
516
+ if (!this.editing)
517
+ return;
518
+ const original = this.graph.links.find((link) => link.id === id);
519
+ if (!original)
520
+ return;
521
+ const error = this.canConnect(source, target, original.mode, duplicate ? undefined : id);
522
+ if (error)
523
+ throw new Error(error);
524
+ const newId = duplicate ? createId("link") : id;
525
+ this.edit(duplicate ? "Duplicate connection" : "Reconnect link", "structure", (graph) => {
526
+ const prepared = resolveNewBoundaryPorts(graph, source, target, this.nodes);
527
+ const next = {
528
+ ...clone(original),
529
+ id: newId,
530
+ source: prepared.source,
531
+ target: prepared.target,
532
+ };
533
+ if (duplicate)
534
+ graph.links.push(next);
535
+ else
536
+ graph.links = graph.links.map((link) => link.id === id ? next : link);
537
+ });
538
+ this.select({ linkIds: [newId] });
539
+ return newId;
540
+ }
541
+ updateLink(id, update) {
542
+ if (update.mode &&
543
+ (!this.modes.get(update.mode) ||
544
+ (this.policy.modes && !this.policy.modes.includes(update.mode))))
545
+ throw new Error("Unavailable link mode.");
546
+ if (update.weight !== undefined && !Number.isFinite(update.weight))
547
+ return;
548
+ this.edit("Edit link", "values", (graph) => {
549
+ const link = graph.links.find((link) => link.id === id);
550
+ if (link)
551
+ Object.assign(link, clone(update));
552
+ });
553
+ }
554
+ deleteSelection(bridge = false) {
555
+ const port = this.#snapshot.selection.port;
556
+ if (port && this.graph.nodes.some(node => node.id === port.nodeId && isGroupBoundary(node))) {
557
+ this.removeBoundaryPort(port.nodeId, port.portId);
558
+ this.select({ nodeIds: [port.nodeId] });
559
+ return;
560
+ }
561
+ if (port && this.policy.deletePort) {
562
+ this.edit('Delete port', 'structure', graph => {
563
+ const node = graph.nodes.find(node => node.id === port.nodeId);
564
+ const definition = node && portsFor(node, this.nodes).find(item => item.id === port.portId && item.direction === port.direction);
565
+ if (node && definition) {
566
+ this.policy.deletePort(node, definition);
567
+ pruneInvalidPorts(graph, this.nodes, node.id);
568
+ }
569
+ });
570
+ this.select({ nodeIds: [port.nodeId] });
571
+ return;
572
+ }
573
+ const selection = this.#snapshot.selection;
574
+ this.edit(bridge ? 'Delete and bridge' : 'Delete selection', 'structure', (graph) => {
575
+ const selected = new Set(selection.nodeIds);
576
+ const bridges = bridge ? graph.nodes.flatMap(node => {
577
+ if (!selected.has(node.id))
578
+ return [];
579
+ const incoming = graph.links.filter(link => link.target.type === 'port' && link.target.nodeId === node.id && !selected.has(link.source.nodeId));
580
+ const outgoing = graph.links.filter(link => link.source.nodeId === node.id && link.target.type === 'port' && !selected.has(link.target.nodeId));
581
+ return incoming.flatMap(input => outgoing.map(output => ({ id: createId('link'), source: { ...input.source }, target: { ...output.target }, mode: output.mode, weight: output.weight, enabled: input.enabled !== false && output.enabled !== false })));
582
+ }) : [];
583
+ const owned = graph.areas.filter(area => selection.areaIds.includes(area.id) && area.ownerNodeId).map(area => area.ownerNodeId);
584
+ removeNodes(graph, [...selection.nodeIds, ...owned]);
585
+ for (const link of bridges)
586
+ if (!connectionError(graph, this.nodes, this.modes, this.policy, link.source, link.target, link.mode))
587
+ graph.links.push(link);
588
+ removeLinks(graph, selection.linkIds);
589
+ graph.areas = graph.areas.filter((area) => !selection.areaIds.includes(area.id));
590
+ for (const area of graph.areas)
591
+ if (area.areaIds)
592
+ area.areaIds = area.areaIds.filter((id) => !selection.areaIds.includes(id));
593
+ });
594
+ this.select({});
595
+ }
596
+ copy() {
597
+ this.#clipboard = copyFragment(this.graph, this.#snapshot.selection.nodeIds, this.#snapshot.selection.areaIds);
598
+ return clone(this.#clipboard);
599
+ }
600
+ paste(fragment = this.#clipboard, offset = { x: 36, y: 36 }, external = false) {
601
+ if (!fragment || !this.editing)
602
+ return;
603
+ let selection = {};
604
+ this.edit("Paste graph", "structure", (graph) => {
605
+ selection = pasteFragment(graph, fragment, this.nodes, offset, external);
606
+ });
607
+ this.select(selection);
608
+ this.#options.onPaste?.({ fragment: clone(fragment), nodeIds: selection.nodeIds ?? [], areaIds: selection.areaIds ?? [], path: [...this.#snapshot.path], document: this.#snapshot.document });
609
+ }
610
+ duplicate(offset = { x: 36, y: 36 }, external = false) {
611
+ this.paste(copyFragment(this.graph, this.#snapshot.selection.nodeIds, this.#snapshot.selection.areaIds), offset, external);
612
+ }
613
+ groupSelection(options) {
614
+ if (!this.editing)
615
+ return;
616
+ let id;
617
+ this.edit("Group selection", "structure", (graph) => {
618
+ id = groupNodes(graph, this.#snapshot.selection.nodeIds.filter(id => !isGroupBoundary(graph.nodes.find(node => node.id === id))), this.nodes, options);
619
+ });
620
+ if (id)
621
+ this.select({ nodeIds: [id] });
622
+ return id;
623
+ }
624
+ insertNode(linkId, kind, position, inputId, outputId) {
625
+ if (!this.editing)
626
+ return;
627
+ const old = this.graph.links.find((link) => link.id === linkId);
628
+ if (!old)
629
+ return;
630
+ const node = this.nodes.create(kind, position);
631
+ const ports = portsFor(node, this.nodes);
632
+ if (!ports.some((port) => port.id === inputId && port.direction === "input") ||
633
+ !ports.some((port) => port.id === outputId && port.direction === "output"))
634
+ throw new Error("Insertion requires an explicit input and output.");
635
+ const candidate = clone(this.graph);
636
+ candidate.nodes.push(node);
637
+ const source = { nodeId: node.id, portId: outputId };
638
+ const input = { type: "port", nodeId: node.id, portId: inputId };
639
+ const error = connectionError(candidate, this.nodes, this.modes, this.policy, old.source, input, "set") ??
640
+ connectionError(candidate, this.nodes, this.modes, this.policy, source, old.target, old.mode, old.id);
641
+ if (error)
642
+ throw new Error(error);
643
+ this.edit("Insert node", "structure", (graph) => {
644
+ graph.nodes.push(node);
645
+ graph.links = graph.links.map((link) => link.id === linkId ? { ...link, source } : link);
646
+ graph.links.push({
647
+ id: createId("link"),
648
+ source: { ...old.source },
649
+ target: input,
650
+ mode: "set",
651
+ weight: 1,
652
+ });
653
+ });
654
+ this.select({ nodeIds: [node.id] });
655
+ return node.id;
656
+ }
657
+ convertNodeToArea(nodeId) {
658
+ if (!this.editing || !this.graph.nodes.some(node => node.id === nodeId))
659
+ return;
660
+ const id = createId('area');
661
+ this.edit('Convert node to area', 'structure', graph => {
662
+ const node = graph.nodes.find(node => node.id === nodeId);
663
+ graph.areas.push({ id, title: node.label ?? 'Area', position: { ...node.position }, size: node.size ?? { width: 420, height: 280 } });
664
+ removeNodes(graph, [nodeId]);
665
+ });
666
+ this.select({ areaIds: [id] });
667
+ return id;
668
+ }
669
+ addArea(position, size = { width: 420, height: 280 }) {
670
+ if (!this.editing)
671
+ return;
672
+ const id = createId("area");
673
+ this.edit("Add area", "layout", (graph) => {
674
+ graph.areas.push({
675
+ id,
676
+ title: "Area",
677
+ position: { ...position },
678
+ size: { ...size },
679
+ });
680
+ });
681
+ this.select({ areaIds: [id] });
682
+ return id;
683
+ }
684
+ updateArea(id, update) {
685
+ this.edit("Edit area", "layout", (graph) => {
686
+ const area = graph.areas.find((area) => area.id === id);
687
+ if (area)
688
+ Object.assign(area, clone(update));
689
+ });
690
+ }
691
+ lockArea(id, locked) {
692
+ this.edit("Lock area membership", "layout", (graph) => {
693
+ const area = graph.areas.find((area) => area.id === id);
694
+ if (!area)
695
+ return;
696
+ const members = areaMembers(graph, id);
697
+ area.locked = locked;
698
+ if (locked) {
699
+ area.nodeIds = [...members.nodeIds];
700
+ area.areaIds = [...members.areaIds];
701
+ }
702
+ else {
703
+ delete area.nodeIds;
704
+ delete area.areaIds;
705
+ }
706
+ });
707
+ }
708
+ moveArea(id, position) {
709
+ this.edit("Move area", "layout", (graph) => {
710
+ const area = graph.areas.find((area) => area.id === id);
711
+ if (!area)
712
+ return;
713
+ const members = areaMembers(graph, id);
714
+ const delta = {
715
+ x: position.x - area.position.x,
716
+ y: position.y - area.position.y,
717
+ };
718
+ area.position = { ...position };
719
+ for (const node of graph.nodes)
720
+ if (members.nodeIds.has(node.id)) {
721
+ node.position.x += delta.x;
722
+ node.position.y += delta.y;
723
+ }
724
+ for (const child of graph.areas)
725
+ if (members.areaIds.has(child.id)) {
726
+ child.position.x += delta.x;
727
+ child.position.y += delta.y;
728
+ }
729
+ });
730
+ }
731
+ }
732
+ //# sourceMappingURL=controller.js.map