@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.
- package/LICENSE +21 -0
- package/README.md +338 -2
- package/dist/controls.css +122 -0
- package/dist/core/controller.d.ts +89 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +732 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/graph.d.ts +30 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +318 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/grouping.d.ts +10 -0
- package/dist/core/grouping.d.ts.map +1 -0
- package/dist/core/grouping.js +130 -0
- package/dist/core/grouping.js.map +1 -0
- package/dist/core/groups.d.ts +40 -0
- package/dist/core/groups.d.ts.map +1 -0
- package/dist/core/groups.js +164 -0
- package/dist/core/groups.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/model.d.ts +182 -0
- package/dist/core/model.d.ts.map +1 -0
- package/dist/core/model.js +149 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/observations.d.ts +10 -0
- package/dist/core/observations.d.ts.map +1 -0
- package/dist/core/observations.js +36 -0
- package/dist/core/observations.js.map +1 -0
- package/dist/core/serialization.d.ts +9 -0
- package/dist/core/serialization.d.ts.map +1 -0
- package/dist/core/serialization.js +148 -0
- package/dist/core/serialization.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/link-controls.css +123 -0
- package/dist/react/Controls.d.ts +18 -0
- package/dist/react/Controls.d.ts.map +1 -0
- package/dist/react/Controls.js +301 -0
- package/dist/react/Controls.js.map +1 -0
- package/dist/react/SpagbolCanvas.d.ts +38 -0
- package/dist/react/SpagbolCanvas.d.ts.map +1 -0
- package/dist/react/SpagbolCanvas.js +1248 -0
- package/dist/react/SpagbolCanvas.js.map +1 -0
- package/dist/react/SpagbolCanvasControls.d.ts +36 -0
- package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasControls.js +113 -0
- package/dist/react/SpagbolCanvasControls.js.map +1 -0
- package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
- package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasFlow.js +6 -0
- package/dist/react/SpagbolCanvasFlow.js.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.js +7 -0
- package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
- package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
- package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasMenu.js +65 -0
- package/dist/react/SpagbolCanvasMenu.js.map +1 -0
- package/dist/react/SpagbolControlNumber.d.ts +25 -0
- package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumber.js +209 -0
- package/dist/react/SpagbolControlNumber.js.map +1 -0
- package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
- package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumberLink.js +159 -0
- package/dist/react/SpagbolControlNumberLink.js.map +1 -0
- package/dist/react/SpagbolControlZoom.d.ts +11 -0
- package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
- package/dist/react/SpagbolControlZoom.js +24 -0
- package/dist/react/SpagbolControlZoom.js.map +1 -0
- package/dist/react/SpagbolNodeFrame.d.ts +35 -0
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrame.js +171 -0
- package/dist/react/SpagbolNodeFrame.js.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.js +62 -0
- package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
- package/dist/react/context.d.ts +84 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +43 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/duplicateDrag.d.ts +19 -0
- package/dist/react/duplicateDrag.d.ts.map +1 -0
- package/dist/react/duplicateDrag.js +71 -0
- package/dist/react/duplicateDrag.js.map +1 -0
- package/dist/react/flowPresentation.d.ts +11 -0
- package/dist/react/flowPresentation.d.ts.map +1 -0
- package/dist/react/flowPresentation.js +50 -0
- package/dist/react/flowPresentation.js.map +1 -0
- package/dist/react/geometry.d.ts +13 -0
- package/dist/react/geometry.d.ts.map +1 -0
- package/dist/react/geometry.js +43 -0
- package/dist/react/geometry.js.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +11 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/dist/react/portState.d.ts +10 -0
- package/dist/react/portState.d.ts.map +1 -0
- package/dist/react/portState.js +35 -0
- package/dist/react/portState.js.map +1 -0
- package/dist/react/viewport.d.ts +24 -0
- package/dist/react/viewport.d.ts.map +1 -0
- package/dist/react/viewport.js +55 -0
- package/dist/react/viewport.js.map +1 -0
- package/dist/styles.css +748 -0
- package/package.json +58 -4
- package/src/core/controller.ts +844 -0
- package/src/core/graph.ts +451 -0
- package/src/core/grouping.ts +159 -0
- package/src/core/groups.ts +125 -0
- package/src/core/index.ts +7 -0
- package/src/core/model.ts +304 -0
- package/src/core/observations.ts +31 -0
- package/src/core/serialization.ts +184 -0
- package/src/index.ts +9 -0
- package/src/react/Controls.tsx +355 -0
- package/src/react/SpagbolCanvas.tsx +1893 -0
- package/src/react/SpagbolCanvasControls.tsx +255 -0
- package/src/react/SpagbolCanvasFlow.tsx +38 -0
- package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
- package/src/react/SpagbolCanvasMenu.tsx +121 -0
- package/src/react/SpagbolControlNumber.tsx +297 -0
- package/src/react/SpagbolControlNumberLink.tsx +209 -0
- package/src/react/SpagbolControlZoom.tsx +102 -0
- package/src/react/SpagbolNodeFrame.tsx +330 -0
- package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
- package/src/react/SpagbolNodeGroupPort.tsx +111 -0
- package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
- package/src/react/context.tsx +146 -0
- package/src/react/controls.css +122 -0
- package/src/react/duplicateDrag.ts +97 -0
- package/src/react/flowPresentation.ts +56 -0
- package/src/react/geometry.ts +53 -0
- package/src/react/index.ts +61 -0
- package/src/react/link-controls.css +123 -0
- package/src/react/nodeDrag.ts +33 -0
- package/src/react/portState.ts +24 -0
- package/src/react/styles.css +748 -0
- 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
|