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